diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..ea541d9
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,21 @@
+name: Tests
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ cache: npm
+ - name: Install zsh
+ run: sudo apt-get update && sudo apt-get install -y zsh
+ - run: npm ci
+ - run: npm test
diff --git a/.gitignore b/.gitignore
index 32d9219..b58a8d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,4 +75,8 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
-# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux
+
+### Node ###
+node_modules/
+*.log
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..e36486f
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,77 @@
+# AGENTS
+
+## Purpose
+
+This document is for contributors and coding agents working on `suitup`.
+User-facing setup and installation guidance belongs in `README.md`.
+
+## Project Structure
+
+- `src/cli.js` is the main entry point.
+- `src/steps/` contains the interactive setup steps.
+- `configs/` contains the files copied into the user's home directory.
+- `tests/` contains Vitest coverage for setup flows and generated files.
+
+## Zsh Architecture
+
+Suitup generates a thin `~/.zshrc` that only orchestrates loading.
+The actual config lives under `~/.config/zsh/`.
+
+### Core files
+
+- `configs/core/perf.zsh`: startup timing helpers and report output
+- `configs/core/env.zsh`: shared environment variables
+- `configs/core/paths.zsh`: PATH placeholder for user overrides
+- `configs/core/options.zsh`: shell and history options
+
+### Shared files
+
+- `configs/shared/tools.zsh`: external tool initialization, including cached init scripts
+- `configs/shared/prompt.zsh`: prompt/theme loading
+
+### Local files
+
+- `configs/local/machine.zsh`: machine-specific overrides placeholder
+- `~/.config/zsh/local/secrets.zsh` is user-managed and intentionally not shipped by suitup
+
+## Templates
+
+- `configs/zshrc.template` is the default zinit-based entrypoint.
+- `configs/zshrc-omz.template` is the Oh My Zsh variant.
+
+Both templates should keep the same loading order unless there is a strong reason to diverge:
+
+1. perf
+2. env
+3. paths
+4. options
+5. tools
+6. plugin manager
+7. suitup-managed additions
+8. local overrides
+9. prompt
+10. timing report
+
+## Step Behavior
+
+`src/steps/zsh-config.js` is responsible for:
+
+- creating `~/.config/zsh/core`, `shared`, and `local`
+- copying shipped config files without overwriting user-modified files
+- generating `.zshrc` from the selected template
+- backing up an existing non-suitup `.zshrc` before overwrite
+
+This step must remain idempotent.
+
+## Testing Notes
+
+- Run `npm test` for the full suite.
+- Zsh config tests live in `tests/zsh-config-steps.test.js`.
+- Tests should verify generated structure and important config content, not just file existence.
+- Avoid asserting machine/tool-specific PATH details in setup tests; keep assertions focused on suitup-managed behavior.
+- Prefer sandboxed home directories over touching real user files.
+
+## Documentation Split
+
+- `README.md`: end-user usage, installed tools, resulting file layout
+- `AGENTS.md`: implementation details, architecture, contributor guidance
diff --git a/README.md b/README.md
index c743160..b631068 100644
--- a/README.md
+++ b/README.md
@@ -5,72 +5,171 @@
height="120">
-## About name
+
+ 简体中文 | English
+
+
+Named after Barney's catchphrase from [How I Met Your Mother](https://www.themoviedb.org/tv/1100-how-i-met-your-mother).
-This name is inspired by Barney's catchphrase, Barney is a character in my favorite TV series: [How I met your mother](https://www.themoviedb.org/tv/1100-how-i-met-your-mother).
+## Features
-## About this repository
+- Interactive TUI powered by [@clack/prompts](https://github.com/bombshell-dev/clack)
+- Modular step selection — install only what you need
+- **Append mode** — add recommended configs to an existing `.zshrc` without replacing it
+- **Verify mode** — check your installation integrity
+- **Clean mode** — remove suitup config files
+- Idempotent — safe to run multiple times
+- No private/company-specific content — clean, generic configs
-This preset will hold every thing in `~/.config/suitup` folder.
+## Usage First
-## Getting started
+### Install and run
+
+```bash
+git clone https://github.com/ChangeHow/suitup.git
+cd suitup
+npm install
+node src/cli.js
+```
-1. run bootstrap in a very first step.
+### Commands
- ```shell
- curl -sL https://raw.githubusercontent.com/changehow/suitup/master/bootstrap.sh | bash
- ```
+| Command | Description |
+|---------|-------------|
+| `node src/cli.js` | Full interactive setup (default) |
+| `node src/cli.js setup` | Same as above |
+| `node src/cli.js append` | Append configs to existing `.zshrc` |
+| `node src/cli.js verify` | Verify installation integrity |
+| `node src/cli.js clean` | Remove suitup config files |
-2. after initial step, you need run `./scripts/init/init-configs.sh` to init configuration folder.
-3. run `./scripts/init/install-zsh-plugins.sh` to install zsh plugins.
-4. run `./scripts/init/apps.sh` to install apps.
-5. run `./scripts/init/command-line-tools.sh` to install command line tools.
-6. run `./scripts/init/front-end.sh` to install front-end tools.
+### What each mode does
-## Apps & Fonts
+### Setup (default)
-1. [Homebrew](https://brew.sh/)
-2. [Oh My Zsh](https://ohmyz.sh/)
-3. [iTerm2](https://iterm2.com/)
-4. [Visual Studio Code](https://code.visualstudio.com/)
-5. [Itsycal](https://www.mowglii.com/itsycal/) A cute calendar for macOS, I really like it.
-6. [Raycast](https://raycast.com/) A powerful tool for macOS, I use it to replace [Alfred](https://www.alfredapp.com/).
-7. [Monaspace](https://monaspace.githubnext.com)
+Interactive step-by-step setup with selectable steps:
-## Zsh plugins
+1. **Bootstrap** — package manager + Zsh
+2. **Zsh Config** — creates `~/.config/zsh/` with layered config architecture
+3. **Plugin Manager** — zinit (recommended) or Oh My Zsh
+4. **CLI Tools** — bat, eza, fzf, fd, zoxide, atuin, ripgrep...
+5. **GUI Apps** — iTerm2, Raycast, VS Code, fonts...
+6. **Frontend Tools** — fnm, pnpm, git-cz
+7. **Shell Aliases** — git, eza, fzf shortcuts
+8. **SSH Key** — generate GitHub SSH key
+9. **Vim Config** — basic vim setup
+10. **Dock Cleanup** — clean macOS Dock
-1. [zplug](https://github.com/zplug/zplug)
-2. [zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions)
-3. [zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting)
+Bootstrap details:
-## CLI tools
-1. [autojump](https://github.com/wting/autojump)
-2. [fzf](https://github.com/junegunn/fzf) and [atuin](https://github.com/atuinsh/atuin)
-3. [bat](https://github.com/sharkdp/bat) a cat clone with wings.
-4. [eza](https://github.com/eza-community/eza) a modern replacement for `ls`.
+- macOS: install Homebrew or skip package manager setup
+- Linux: choose `apt-get`, `dnf`, `yum`, `brew`, or skip
+
+### Append
+
+For users who already have a `.zshrc` and want to cherry-pick suitup configs:
+
+```bash
+node src/cli.js append
+```
-## Vim & Aliases
-We also provide vim configuration and some aliases:
-1. using `jk` to replace `ESC` in vim.
-2. using `` to quick navigating in vim.
-3. add some base settings to vim.
-4. add some aliases like `gph:git push`, `gpl:git pull --rebase`, `gco:git checkout`...
+Uses idempotent marker blocks (`# >>> suitup/... >>>`) to safely append selected configs:
-# How to reset/reinstall
+- Suitup aliases
+- Zinit plugins
+- Tool initialization (atuin, fzf, zoxide, fnm)
+- Zsh options (history, completion)
+- Environment variables
+- Startup performance monitor
+- FZF configuration
-Run this command and reset to default zsh config(`.zshrc`)
+### Verify
-```shell
-sh ./clean.sh
+```bash
+node src/cli.js verify
```
-This script will do:
+Checks config files, CLI tool availability, and shell syntax validity.
+
+### Clean
+
+```bash
+node src/cli.js clean
+```
+
+Removes `~/.config/suitup/`. Does NOT remove `~/.zshrc` or `~/.config/zsh/` — remove those manually if needed.
+
+## What suitup installs
+
+### CLI tools
+
+| Tool | Replaces | Description |
+|------|----------|-------------|
+| [bat](https://github.com/sharkdp/bat) | `cat` | Syntax-highlighted file viewer |
+| [eza](https://github.com/eza-community/eza) | `ls` | Modern file listing |
+| [fzf](https://github.com/junegunn/fzf) | — | Fuzzy finder |
+| [fd](https://github.com/sharkdp/fd) | `find` | Fast file search |
+| [atuin](https://github.com/atuinsh/atuin) | `ctrl-r` | Shell history search |
+| [zoxide](https://github.com/ajeetdsouza/zoxide) | `cd` | Smart directory jumping |
+| [ripgrep](https://github.com/BurntSushi/ripgrep) | `grep` | Fast content search |
+
+### Zsh plugins
+
+- [zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions)
+- [zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting)
+- [powerlevel10k](https://github.com/romkatv/powerlevel10k) theme
+
+### GUI apps
+
+Selectable during setup: iTerm2, Raycast, VS Code, Itsycal, Monaspace font, and more.
+
+### Frontend toolchain
+
+- [fnm](https://github.com/Schniz/fnm) — Fast Node Manager
+- [pnpm](https://pnpm.io/) — Fast, disk-efficient package manager
+- [git-cz](https://github.com/streamich/git-cz) — Conventional commits CLI
+
+## Installed file layout
+
+After setup, your shell config looks like:
+
+```
+~/.zshrc # Thin orchestrator
+~/.config/zsh/
+ core/
+ perf.zsh # Startup timing
+ env.zsh # Environment variables
+ paths.zsh # PATH placeholder
+ options.zsh # Zsh shell options
+ shared/
+ tools.zsh # Tool init (fzf, atuin, zoxide, fnm)
+ prompt.zsh # Prompt/theme (p10k)
+ local/
+ machine.zsh # Machine-specific overrides
+ secrets.zsh # API keys (create manually, gitignored)
+~/.config/suitup/
+ aliases # Shell aliases
+ zinit-plugins # Zinit plugin config
+ config.vim # Vim config
+```
+
+## Testing
+
+```bash
+npm test # Run all tests
+npm run test:watch # Watch mode
+```
+
+Tests run in sandboxed temp directories.
+
+Implementation details and architecture notes live in `AGENTS.md`.
+
+## Requirements
-1. remove `~/.config/suitup`
-2. remove `~/.oh-my-zsh`
-3. remove `~/.zshrc`
-4. switch to `bash`, _or you can keep `zsh` if you macOS has changed the default shell to zsh_.
+- macOS (full support, tested on Sonoma+)
+- Linux (bootstrap package-manager selection supported; most install steps still target Homebrew ecosystem)
+- Node.js >= 18
+- Zsh (default shell on macOS)
-# In the end
+## License
-After doing this, you can enjoy your development journey. 🎉
+[Apache-2.0](LICENSE)
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000..5ebc267
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,164 @@
+# Suit up!
+
+
+
+
+
+
+ 简体中文 | English
+
+
+名字取自 [老爸老妈的浪漫史](https://www.themoviedb.org/tv/1100-how-i-met-your-mother) 中 Barney 的口头禅。
+
+## 特性
+
+- 基于 [@clack/prompts](https://github.com/bombshell-dev/clack) 的交互式终端界面
+- 模块化步骤选择,只安装你需要的内容
+- **追加模式**:向现有 `.zshrc` 追加推荐配置,不强制覆盖
+- **验证模式**:检查安装完整性
+- **清理模式**:删除 suitup 生成的配置
+- 幂等执行,可安全重复运行
+- 不包含私有/公司特定内容
+
+## 用法优先
+
+### 安装并运行
+
+```bash
+git clone https://github.com/ChangeHow/suitup.git
+cd suitup
+npm install
+node src/cli.js
+```
+
+### 命令
+
+| 命令 | 说明 |
+|------|------|
+| `node src/cli.js` | 完整交互式安装(默认) |
+| `node src/cli.js setup` | 同上 |
+| `node src/cli.js append` | 追加配置到已有 `.zshrc` |
+| `node src/cli.js verify` | 验证安装完整性 |
+| `node src/cli.js clean` | 删除 suitup 配置文件 |
+
+### 模式说明
+
+### Setup(默认)
+
+交互式步骤如下:
+
+1. **Bootstrap** — 包管理器 + Zsh(macOS 可安装/跳过 Homebrew;Linux 可选 apt-get/dnf/yum/brew/跳过)
+2. **Zsh Config** — 创建 `~/.config/zsh/` 分层结构
+3. **Plugin Manager** — zinit(推荐)或 Oh My Zsh
+4. **CLI Tools** — bat、eza、fzf、fd、zoxide、atuin、ripgrep 等
+5. **GUI Apps** — iTerm2、Raycast、VS Code、字体等
+6. **Frontend Tools** — fnm、pnpm、git-cz
+7. **Shell Aliases** — git、eza、fzf 等快捷命令
+8. **SSH Key** — 生成 GitHub SSH 密钥
+9. **Vim Config** — 基础 Vim 配置
+10. **Dock Cleanup** — 清理 macOS Dock
+
+### Append(追加)
+
+适用于已有 `.zshrc`,想按需接入 suitup 配置:
+
+```bash
+node src/cli.js append
+```
+
+通过幂等标记块(`# >>> suitup/... >>>`)安全追加:
+
+- aliases
+- zinit 插件
+- 工具初始化(atuin/fzf/zoxide/fnm)
+- Zsh 选项(history/completion)
+- 环境变量
+- 启动性能报告
+- FZF 配置
+
+### Verify(验证)
+
+```bash
+node src/cli.js verify
+```
+
+检查配置文件、CLI 可用性、Shell 语法。
+
+### Clean(清理)
+
+```bash
+node src/cli.js clean
+```
+
+删除 `~/.config/suitup/`。不会删除 `~/.zshrc` 与 `~/.config/zsh/`。
+
+## suitup 会安装什么
+
+### CLI 工具
+
+| 工具 | 替代 | 说明 |
+|------|------|------|
+| [bat](https://github.com/sharkdp/bat) | `cat` | 带语法高亮的文件查看器 |
+| [eza](https://github.com/eza-community/eza) | `ls` | 现代文件列表 |
+| [fzf](https://github.com/junegunn/fzf) | — | 模糊搜索 |
+| [fd](https://github.com/sharkdp/fd) | `find` | 快速文件搜索 |
+| [atuin](https://github.com/atuinsh/atuin) | `ctrl-r` | Shell 历史搜索 |
+| [zoxide](https://github.com/ajeetdsouza/zoxide) | `cd` | 智能目录跳转 |
+| [ripgrep](https://github.com/BurntSushi/ripgrep) | `grep` | 快速内容搜索 |
+
+### Zsh 插件
+
+- [zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions)
+- [zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting)
+- [powerlevel10k](https://github.com/romkatv/powerlevel10k) 主题
+
+### GUI 应用
+
+可在安装过程中选择:iTerm2、Raycast、VS Code、Itsycal、Monaspace 字体等。
+
+### 前端工具链
+
+- [fnm](https://github.com/Schniz/fnm) — Node 版本管理
+- [pnpm](https://pnpm.io/) — 高性能包管理器
+- [git-cz](https://github.com/streamich/git-cz) — Conventional Commits CLI
+
+## 安装后的目录结构
+
+```text
+~/.zshrc # 轻量入口
+~/.config/zsh/
+ core/
+ perf.zsh # 启动计时
+ env.zsh # 环境变量
+ paths.zsh # PATH 预留文件
+ options.zsh # Zsh 选项
+ shared/
+ tools.zsh # 工具初始化(带缓存)
+ prompt.zsh # 提示符主题
+ local/
+ machine.zsh # 机器本地覆盖
+ secrets.zsh # 个人密钥(手动创建)
+~/.config/suitup/
+ aliases # Shell aliases
+ zinit-plugins # Zinit 插件配置
+ config.vim # Vim 配置
+```
+
+## 系统要求
+
+- Node.js >= 18
+- Zsh
+- macOS(完整支持)
+- Linux(支持 bootstrap 包管理器选择;其余安装步骤当前仍以 Homebrew 生态为主)
+
+## 测试
+
+```bash
+npm test
+npm run test:watch
+```
+
+## 许可证
+
+[Apache-2.0](LICENSE)
diff --git a/bootstrap.sh b/bootstrap.sh
deleted file mode 100755
index e201a8c..0000000
--- a/bootstrap.sh
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/bin/bash
-
-set -e
-
-# define some colors
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-color_echo() {
- COLOR=$1
- shift
- echo -e "${!COLOR}$@${NC}"
-}
-
-prefix_log() {
- local prefix=${2:-log}
- color_echo BLUE "[$prefix] $1"
-}
-
-# Check if Homebrew is installed
-if ! command -v brew &> /dev/null
-then
- color_echo BLUE "Homebrew is not installed, installing now..."
- /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- (echo; echo 'eval "$(/opt/homebrew/bin/brew shellenv)"') >> ~/.zprofile
- eval "$(/opt/homebrew/bin/brew shellenv)"
-else
- color_echo GREEN "Homebrew is already installed"
-fi
-
-# Install Zsh if not installed
-if ! command -v zsh &> /dev/null
-then
- color_echo BLUE "Zsh is not installed, installing now..."
- brew install zsh
-else
- color_echo GREEN "Zsh is already installed"
-fi
-
-# Install Oh My Zsh if not installed
-if [ ! -d "$HOME/.oh-my-zsh" ]
-then
- color_echo BLUE "Oh My Zsh is not installed, installing now..."
- /bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
-else
- color_echo GREEN "Oh My Zsh is already installed"
-fi
-
-# Set Zsh as default shell if not already set
-if [ "$SHELL" != "$(which zsh)" ]
-then
- color_echo BLUE "Setting Zsh as the default shell"
- chsh -s "$(which zsh)"
-else
- color_echo GREEN "Zsh is already set as the default shell"
-fi
-
-color_echo YELLOW "You can use 'exec zsh' to reload zsh"
-
-# notice user to clone respository changehow/suitupt
-color_echo YELLOW "Don't forget to clone the repository https://github.com/ChangeHow/suitup"
-
-# Restart shell
-exec zsh
diff --git a/clean.sh b/clean.sh
deleted file mode 100644
index de44e9d..0000000
--- a/clean.sh
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-
-set -e
-
-# 删除指定目录下的文件
-if [ -d "$HOME/.config/suitup" ]; then
- rm -rf $HOME/.config/suitup
- color_echo GREEN "Removed ~/.config/suitup"
-fi
-
-if [ -d "$HOME/.oh-my-zsh" ]; then
- rm -rf $HOME/.oh-my-zsh
- color_echo GREEN "Removed ~/.oh-my-zsh"
-fi
-
-# 检查 .zshrc 文件是否存在
-if [ -f "$HOME/.zshrc" ]; then
- # 如果存在,则删除它
- rm $HOME/.zshrc
-fi
-
-echo "Now you can run $(color_echo YELLOW "sudo chsh -s $(which bash)") and $(color_echo YELLOW "exec bash") to switch shell"
diff --git a/configs/aliases b/configs/aliases
new file mode 100644
index 0000000..21effd0
--- /dev/null
+++ b/configs/aliases
@@ -0,0 +1,28 @@
+# >>> suitup aliases >>>
+
+# utilities
+alias reload-zsh="source ~/.zshrc"
+alias edit-zsh="${EDITOR:-vi} ~/.zshrc"
+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"
+alias cat="bat"
+
+# git
+alias gco="git checkout"
+alias gph="git push"
+alias gphu='gph -u origin "$(git branch --show-current 2>/dev/null)"'
+alias gcol="git checkout --no-guess"
+alias gpl="git pull --rebase"
+alias gcz="git-cz"
+alias gczn="git-cz -n"
+alias gst="git status --short"
+alias glg="git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %Cgreen(%cr) %C(bold blue)<%ae>%Creset%n%s' --abbrev-commit"
+alias gss="git restore --staged ."
+alias resolve-pnpmlock="git checkout --ours pnpm-lock.yaml && git add pnpm-lock.yaml"
+
+# search
+alias ss="fzf --walker-skip .git,node_modules --preview 'bat -n --color=always {}'"
+alias zx='zoxide query --interactive'
+
+# <<< suitup aliases <<<
diff --git a/configs/config.vim b/configs/config.vim
new file mode 100644
index 0000000..353171f
--- /dev/null
+++ b/configs/config.vim
@@ -0,0 +1,16 @@
+" common settings
+syntax enable
+set relativenumber
+set expandtab
+set tabstop=2
+set shiftwidth=2
+set cursorline
+hi CursorLine cterm=NONE ctermbg=235 guibg=Grey10
+
+" shortcuts
+imap jk
+" navigation
+nmap 3j
+nmap 3k
+nmap 3e
+nmap 3b
diff --git a/configs/core/env.zsh b/configs/core/env.zsh
new file mode 100644
index 0000000..10af733
--- /dev/null
+++ b/configs/core/env.zsh
@@ -0,0 +1,6 @@
+# ============================================================================
+# General environment variables
+# ============================================================================
+
+export ATUIN_CTRL_R_ENABLED=true
+export BAT_THEME="TwoDark"
diff --git a/configs/core/options.zsh b/configs/core/options.zsh
new file mode 100644
index 0000000..6fd9318
--- /dev/null
+++ b/configs/core/options.zsh
@@ -0,0 +1,27 @@
+# ============================================================================
+# Zsh shell options and history settings
+# ============================================================================
+
+# Auto cd
+setopt AUTO_CD
+
+# History settings
+export HISTFILE=~/.zsh_history
+export HISTSIZE=50000
+export SAVEHIST=50000
+export HIST_STAMPS="yyyy-mm-dd"
+
+# History options
+setopt EXTENDED_HISTORY
+setopt INC_APPEND_HISTORY
+setopt HIST_IGNORE_ALL_DUPS
+setopt HIST_FIND_NO_DUPS
+setopt HIST_SAVE_NO_DUPS
+setopt HIST_REDUCE_BLANKS
+
+# Completion options
+setopt COMPLETE_IN_WORD
+setopt ALWAYS_TO_END
+setopt CORRECT
+setopt GLOB_COMPLETE
+setopt NO_CASE_GLOB
diff --git a/configs/core/paths.zsh b/configs/core/paths.zsh
new file mode 100644
index 0000000..6812168
--- /dev/null
+++ b/configs/core/paths.zsh
@@ -0,0 +1,5 @@
+# ============================================================================
+# PATH configuration placeholder
+# ============================================================================
+
+# Keep this file for user PATH overrides if needed.
diff --git a/configs/core/perf.zsh b/configs/core/perf.zsh
new file mode 100644
index 0000000..6427605
--- /dev/null
+++ b/configs/core/perf.zsh
@@ -0,0 +1,78 @@
+# ============================================================================
+# Startup timing and performance report
+# ============================================================================
+
+zmodload zsh/datetime 2>/dev/null
+
+if (( ! ${+EPOCHREALTIME} )); then
+ # zsh/datetime unavailable; define no-op stubs so _stage/_zsh_report calls still succeed
+ _stage() { :; }
+ _zsh_report() { :; }
+ return
+fi
+
+typeset -ga _zsh_stage_names
+typeset -ga _zsh_stage_durations
+typeset -g _zsh_report_called
+typeset -g _zsh_current_stage
+typeset -gF 6 _zsh_start_time
+typeset -gF 6 _zsh_stage_started_at
+
+_zsh_start_time=${EPOCHREALTIME:-0}
+_zsh_stage_started_at=$_zsh_start_time
+_zsh_stage_names=()
+_zsh_stage_durations=()
+_zsh_report_called=false
+_zsh_current_stage=''
+
+_record_stage_duration() {
+ [[ -z "$_zsh_current_stage" ]] && return
+
+ local -F 6 now=${EPOCHREALTIME:-0}
+ local elapsed_ms=$(( (now - _zsh_stage_started_at) * 1000.0 ))
+
+ _zsh_stage_names+=("$_zsh_current_stage")
+ _zsh_stage_durations+=("${elapsed_ms}")
+ _zsh_stage_started_at=$now
+}
+
+_stage() {
+ _record_stage_duration
+ _zsh_current_stage="$1"
+}
+
+_print_duration_row() {
+ local name="$1"
+ local raw_ms="$2"
+ local rounded_ms=$(printf '%.0f' "$raw_ms")
+
+ if (( rounded_ms > 1000 )); then
+ local sec=$(( rounded_ms / 1000 ))
+ local rem=$(( rounded_ms % 1000 ))
+ printf '│ %-8s %12d.%01ds │\n' "$name" "$sec" "$(( rem / 100 ))"
+ else
+ printf '│ %-8s %13dms │\n' "$name" "$rounded_ms"
+ fi
+}
+
+_zsh_report() {
+ [[ "$_zsh_report_called" == "true" ]] && return
+ _zsh_report_called=true
+
+ _record_stage_duration
+
+ local -F 6 end=${EPOCHREALTIME:-0}
+ local -F 6 total_ms=$(( (end - _zsh_start_time) * 1000.0 ))
+ local i
+
+ echo ''
+ echo '┌──────────────────────────┐'
+
+ for i in {1..${#_zsh_stage_names}}; do
+ _print_duration_row "${_zsh_stage_names[$i]}" "${_zsh_stage_durations[$i]}"
+ done
+
+ echo '├──────────────────────────┤'
+ _print_duration_row 'total' "$total_ms"
+ echo '└──────────────────────────┘'
+}
diff --git a/configs/local/machine.zsh b/configs/local/machine.zsh
new file mode 100644
index 0000000..0d8d241
--- /dev/null
+++ b/configs/local/machine.zsh
@@ -0,0 +1,9 @@
+# ============================================================================
+# Machine-specific overrides
+# ============================================================================
+# Add local path additions, work profile toggles, or any other settings
+# that apply only to this machine.
+#
+# Example:
+# export ZSH_WORK_PROFILE=1 # load work/* config on this machine
+# path=("/usr/local/custom/bin" $path)
diff --git a/configs/shared/fzf.zsh b/configs/shared/fzf.zsh
new file mode 100644
index 0000000..5f97a30
--- /dev/null
+++ b/configs/shared/fzf.zsh
@@ -0,0 +1,33 @@
+# FZF
+# Note: home-directory branch returns nothing (no empty line) to avoid
+# cluttering the picker when running fzf from $HOME.
+export FZF_DEFAULT_COMMAND='
+ if [[ "$PWD" != "$HOME" ]]; then
+ fd --type d --hidden --follow \
+ --base-directory . \
+ --exclude node_modules --exclude .git --exclude dist --exclude output --exclude tmp \
+ 2>/dev/null;
+ fd --type f --hidden --follow \
+ --base-directory . \
+ --exclude node_modules --exclude .git --exclude dist --exclude output --exclude tmp \
+ 2>/dev/null
+ fi
+'
+
+export FZF_CTRL_T_COMMAND=$FZF_DEFAULT_COMMAND
+
+export FZF_CTRL_T_OPTS="
+ --height 100%
+ --header '[C-/] toggle preview | [Alt-j/k] scroll preview'
+ --preview 'if [ -f {} ]; then
+ bat --color=always --style=plain --line-range :300 {};
+ elif [ -d {} ]; then
+ eza -L 2 -T --git-ignore {} 2>/dev/null | head -20;
+ fi'
+ --preview-window=right:50%:wrap
+ --bind 'ctrl-/:toggle-preview'
+ --bind 'alt-j:preview-down'
+ --bind 'alt-k:preview-up'
+ --bind 'ctrl-d:preview-page-down'
+ --bind 'ctrl-u:preview-page-up'
+"
diff --git a/configs/shared/prompt.zsh b/configs/shared/prompt.zsh
new file mode 100644
index 0000000..685ee2c
--- /dev/null
+++ b/configs/shared/prompt.zsh
@@ -0,0 +1,9 @@
+# ============================================================================
+# Prompt / theme loading — must run last
+# ============================================================================
+
+# For zinit-based setups: load p10k as the final plugin so it wraps everything
+(( ${+functions[zinit]} )) && { zinit ice depth"1"; zinit light romkatv/powerlevel10k; }
+
+# Apply p10k configuration
+[[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh
diff --git a/configs/shared/tools.zsh b/configs/shared/tools.zsh
new file mode 100644
index 0000000..900db55
--- /dev/null
+++ b/configs/shared/tools.zsh
@@ -0,0 +1,37 @@
+# ============================================================================
+# External tool configuration and initialization
+# ============================================================================
+
+# FZF configuration (env vars for fd-based search and preview bindings)
+[[ -f "${ZDOTDIR:-$HOME/.config/zsh}/shared/fzf.zsh" ]] && source "${ZDOTDIR:-$HOME/.config/zsh}/shared/fzf.zsh"
+
+_zsh_tools_cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/zsh"
+[[ -d "$_zsh_tools_cache_dir" ]] || mkdir -p "$_zsh_tools_cache_dir"
+
+_source_cached_tool_init() {
+ local cache_name="$1"
+ local binary_name="$2"
+ local init_command="$3"
+ local binary_path
+ local cache_file="$_zsh_tools_cache_dir/${cache_name}.zsh"
+ local tmp_file="${cache_file}.tmp"
+
+ binary_path=$(command -v "$binary_name") || return 0
+
+ if [[ ! -s "$cache_file" || "$binary_path" -nt "$cache_file" ]]; then
+ if eval "$init_command" >| "$tmp_file" 2>/dev/null; then
+ mv "$tmp_file" "$cache_file"
+ else
+ rm -f "$tmp_file"
+ eval "$init_command"
+ return
+ fi
+ fi
+
+ source "$cache_file"
+}
+
+_source_cached_tool_init atuin-init atuin 'atuin init zsh'
+_source_cached_tool_init fzf-init fzf 'fzf --zsh'
+_source_cached_tool_init zoxide-init zoxide 'zoxide init zsh'
+_source_cached_tool_init fnm-init fnm 'fnm env --use-on-cd --version-file-strategy=recursive --shell zsh'
diff --git a/configs/zinit-plugins b/configs/zinit-plugins
new file mode 100644
index 0000000..5953007
--- /dev/null
+++ b/configs/zinit-plugins
@@ -0,0 +1,6 @@
+# >>> suitup zinit-plugins >>>
+
+zinit load 'zsh-users/zsh-autosuggestions'
+zinit load 'zsh-users/zsh-syntax-highlighting'
+
+# <<< suitup zinit-plugins <<<
diff --git a/configs/zshrc-omz.template b/configs/zshrc-omz.template
new file mode 100644
index 0000000..6799647
--- /dev/null
+++ b/configs/zshrc-omz.template
@@ -0,0 +1,61 @@
+# ============================================================================
+# Zsh entry point — Oh My Zsh variant
+# Generated by suitup (https://github.com/ChangeHow/suitup)
+# ============================================================================
+
+export ZSH="$HOME/.oh-my-zsh"
+export ZSH_CONFIG="$HOME/.config/zsh"
+
+# ---------------------------------------------------------------------------
+# Helper
+# ---------------------------------------------------------------------------
+source_if_exists() { [[ -f "$1" ]] && source "$1"; }
+
+# ---------------------------------------------------------------------------
+# Core
+# ---------------------------------------------------------------------------
+source "$ZSH_CONFIG/core/perf.zsh"
+
+_stage "env"
+source "$ZSH_CONFIG/core/env.zsh"
+
+_stage "paths"
+source "$ZSH_CONFIG/core/paths.zsh"
+
+_stage "options"
+source "$ZSH_CONFIG/core/options.zsh"
+
+# ---------------------------------------------------------------------------
+# Tools
+# ---------------------------------------------------------------------------
+_stage "tools"
+source "$ZSH_CONFIG/shared/tools.zsh"
+
+# ---------------------------------------------------------------------------
+# Oh My Zsh
+# ---------------------------------------------------------------------------
+_stage "omz"
+ZSH_THEME="powerlevel10k/powerlevel10k"
+plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
+source "$ZSH/oh-my-zsh.sh"
+
+# Suitup
+_stage "suitup"
+source_if_exists "$HOME/.config/suitup/aliases"
+
+# ---------------------------------------------------------------------------
+# Local overrides
+# ---------------------------------------------------------------------------
+source_if_exists "$ZSH_CONFIG/local/secrets.zsh"
+source_if_exists "$ZSH_CONFIG/local/machine.zsh"
+
+# ---------------------------------------------------------------------------
+# Prompt (last)
+# ---------------------------------------------------------------------------
+_stage "prompt"
+source "$ZSH_CONFIG/shared/prompt.zsh"
+
+# ---------------------------------------------------------------------------
+# Startup timing report
+# ---------------------------------------------------------------------------
+_zsh_report
diff --git a/configs/zshrc.template b/configs/zshrc.template
new file mode 100644
index 0000000..3d9169c
--- /dev/null
+++ b/configs/zshrc.template
@@ -0,0 +1,60 @@
+# ============================================================================
+# Zsh entry point — orchestration only
+# Generated by suitup (https://github.com/ChangeHow/suitup)
+# ============================================================================
+
+export ZSH_CONFIG="$HOME/.config/zsh"
+
+# ---------------------------------------------------------------------------
+# Helper
+# ---------------------------------------------------------------------------
+source_if_exists() { [[ -f "$1" ]] && source "$1"; }
+
+# ---------------------------------------------------------------------------
+# Core
+# ---------------------------------------------------------------------------
+source "$ZSH_CONFIG/core/perf.zsh"
+
+_stage "env"
+source "$ZSH_CONFIG/core/env.zsh"
+
+_stage "paths"
+source "$ZSH_CONFIG/core/paths.zsh"
+
+_stage "options"
+source "$ZSH_CONFIG/core/options.zsh"
+
+# ---------------------------------------------------------------------------
+# Tools
+# ---------------------------------------------------------------------------
+_stage "tools"
+source "$ZSH_CONFIG/shared/tools.zsh"
+
+# ---------------------------------------------------------------------------
+# Plugin manager
+# ---------------------------------------------------------------------------
+_stage "zinit"
+ZINIT_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/zinit/zinit.git"
+source "${ZINIT_HOME}/zinit.zsh"
+
+# Suitup
+_stage "suitup"
+source_if_exists "$HOME/.config/suitup/zinit-plugins"
+source_if_exists "$HOME/.config/suitup/aliases"
+
+# ---------------------------------------------------------------------------
+# Local overrides
+# ---------------------------------------------------------------------------
+source_if_exists "$ZSH_CONFIG/local/secrets.zsh"
+source_if_exists "$ZSH_CONFIG/local/machine.zsh"
+
+# ---------------------------------------------------------------------------
+# Prompt (last — must not be affected by earlier setup noise)
+# ---------------------------------------------------------------------------
+_stage "prompt"
+source "$ZSH_CONFIG/shared/prompt.zsh"
+
+# ---------------------------------------------------------------------------
+# Startup timing report
+# ---------------------------------------------------------------------------
+_zsh_report
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1a4843b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1616 @@
+{
+ "name": "suitup",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "suitup",
+ "version": "2.0.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@clack/prompts": "^0.11.0",
+ "picocolors": "^1.1.1"
+ },
+ "devDependencies": {
+ "vitest": "^3.1.1"
+ }
+ },
+ "node_modules/@clack/core": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz",
+ "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==",
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.0.0",
+ "sisteransi": "^1.0.5"
+ }
+ },
+ "node_modules/@clack/prompts": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz",
+ "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@clack/core": "0.5.0",
+ "picocolors": "^1.0.0",
+ "sisteransi": "^1.0.5"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..936b8d7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "suitup",
+ "version": "2.0.0",
+ "description": "Opinionated macOS development environment setup tool with beautiful TUI",
+ "type": "module",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "scripts": {
+ "start": "node src/cli.js",
+ "setup": "node src/cli.js setup",
+ "append": "node src/cli.js append",
+ "verify": "node src/cli.js verify",
+ "clean": "node src/cli.js clean",
+ "test": "zsh tests/perf.zsh && vitest run",
+ "test:watch": "vitest"
+ },
+ "keywords": ["dotfiles", "macos", "setup", "zsh", "cli"],
+ "author": "ChangeHow",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@clack/prompts": "^0.11.0",
+ "picocolors": "^1.1.1"
+ },
+ "devDependencies": {
+ "vitest": "^3.1.1"
+ }
+}
diff --git a/scripts/dev/vim-config.sh b/scripts/dev/vim-config.sh
deleted file mode 100755
index 453f81b..0000000
--- a/scripts/dev/vim-config.sh
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-
-prefix="vim"
-custom_vim_cfg=$HOME/.config/suitup/config.vim
-
-# Check if .vimrc file exists, if not, create it
-if [ ! -f $HOME/.vimrc ]; then
- touch $HOME/.vimrc
- prefix_log "Created .vimrc configuration file." $prefix
-else
- prefix_log ".vimrc configuration file already exists." $prefix
-fi
-
-if [ ! -f $custom_vim_cfg ]; then
- touch $custom_vim_cfg
- prefix_log "Created $custom_vim_cfg" $prefix
-fi
-
-# Define vim configuration as a variable
-vim_config='
-" common settings
-syntax enable
-set relativenumber
-set expandtab
-set tabstop=2
-set shiftwidth=2
-set cursorline
-hi CursorLine cterm=NONE ctermbg=235 guibg=Grey10
-
-" shortcuts
-imap jk
-"" navigation
-nmap 3j
-nmap 3k
-nmap 3e
-nmap 3b
-'
-
-# Add vim configuration to .vimrc
-echo "$vim_config" >$custom_vim_cfg
-
-if ! grep -q "source $custom_vim_cfg" "$HOME/.vimrc"; then
- prefix_log "load custom vim config" $prefix
- # 并在 zshrc 文件中引用它
- echo "source $custom_vim_cfg" >>"$HOME/.vimrc"
-fi
-
-prefix_log "Updated .vimrc configuration." $prefix
diff --git a/scripts/dev/zsh-alias.sh b/scripts/dev/zsh-alias.sh
deleted file mode 100755
index c2c2edf..0000000
--- a/scripts/dev/zsh-alias.sh
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-source $(pwd)/scripts/init/init-configs.sh
-
-prefix="zsh alias"
-aliases_file=$HOME/.config/suitup/aliases
-plugins_file=$HOME/.config/suitup/plugins
-# this script will add some alias to .config/zsh/aliases
-
-prefix_log "add aliases to .config/zsh/alias" $prefix
-
-append_to "alias reload-zsh=\"exec zsh\"" $aliases_file
-
-# edit zsh
-prefix_log "you can use \"edit-zsh\" to edit zshrc file" $prefix
-append_to "alias edit-zsh=\"vi $HOME/.zshrc\"" $aliases_file
-color_echo YELLOW "You can use 'edit-zsh' to edit zshrc file"
-
-# edit aliases
-prefix_log "you can use \"edit-aliases\" to edit aliases file" $prefix
-append_to "alias edit-aliases=\"vi $aliases_file\"" $aliases_file
-color_echo YELLOW "You can use 'edit-aliases' to edit aliases file"
-
-# edit plugins
-prefix_log "you can use \"edit-plugins\" to edit plugins file" $prefix
-append_to "alias edit-plugins=\"vi $plugins_file\"" $aliases_file
-color_echo YELLOW "You can use 'edit-plugins' to edit plugins file"
-
-prefix_log "display colorful file tree with ll command" $prefix
-append_to "alias ll=\"eza -abghlS\"" $aliases_file
-append_to "alias ltree=\"eza -T\"" $aliases_file
-
-prefix_log "using bat instead of cat" $prefix
-append_to_zshrc "export BAT_THEME=\"TwoDark\""
-append_to "alias cat=\"bat\"" $aliases_file
-
-prefix_log "you can use \"gph\" to push branch" $prefix
-append_to "alias gph=\"git push\"" $aliases_file
-
-prefix_log "you can use \"gpl\" to pull branch" $prefix
-append_to "alias gpl=\"git pull --rebase\"" $aliases_file
-
-prefix_log "you can use \"gcz\" to commit with commitizen" $prefix
-append_to "alias gcz=\"git-cz\"" $aliases_file
-color_echo YELLOW "Using 'gcz' to call a interactive interface to submit you commits"
-
-prefix_log "you can use \"gst\" to print git status" $prefix
-append_to "alias gst=\"git status\"" $aliases_file
-
-prefix_log "you can use \"glg\" to output formatted git logs" $prefix
-append_to "alias glg=\"git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit\"" $aliases_file
-
-prefix_log "completed" $prefix
diff --git a/scripts/init/apps.sh b/scripts/init/apps.sh
deleted file mode 100755
index 22a98cd..0000000
--- a/scripts/init/apps.sh
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-
-prefix="apps"
-
-function install_if_not_present() {
- local app_name=$1
- local brew_name=$2
- local brew_type=$3
-
- # check if app is already installed
- if ! brew list $brew_name &>/dev/null; then
- prefix_log "Installing $app_name..." $prefix
- # if brew type is not provided, default to install formula
- if [ -z "$brew_type" ]; then
- brew install $brew_name
- elif [ "$brew_type" == "cask" ]; then
- brew install --cask $brew_name
- fi
- else
- prefix_log "$app_name is already installed. Skipping." $prefix
- fi
-}
-
-if which brew >/dev/null; then
- prefix_log "Homebrew is installed." $prefix
-else
- prefix_log "Homebrew is not installed." $prefix
-fi
-
-install_if_not_present "iTerm2" "iterm2" "cask"
-install_if_not_present "Raycast" "raycast" "cask"
-# install_if_not_present "Google Chrome" "google-chrome" "cask"
-install_if_not_present "Visual Studio Code" "visual-studio-code" "cask"
-# install_if_not_present "Orbstack" "orbstack" "cask"
-install_if_not_present "Itsycal" "itsycal" "cask"
-install_if_not_present "Postman" "postman" "cask"
-install_if_not_present "Pap.er: a wallpaper app" "paper" "cask"
-# font
-install_if_not_present "Font: Monaspace" "font-monaspace" "cask"
-install_if_not_present "Font: Space Mono" "font-space-mono" "cask"
diff --git a/scripts/init/clean-dock.sh b/scripts/init/clean-dock.sh
deleted file mode 100644
index 507470b..0000000
--- a/scripts/init/clean-dock.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-
-prefix_log "removing default dock items..." "dock"
-defaults write com.apple.dock persistent-apps -array
-# enable magic effect & set magnification to large
-defaults write com.apple.dock mineffect -string "genie"
-defaults write com.apple.dock largesize -int 90
-killall Dock
\ No newline at end of file
diff --git a/scripts/init/command-line-tools.sh b/scripts/init/command-line-tools.sh
deleted file mode 100755
index fbf3023..0000000
--- a/scripts/init/command-line-tools.sh
+++ /dev/null
@@ -1,99 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-source $(pwd)/scripts/init/init-configs.sh
-
-prefix="cli"
-
-# 检查 brew 是否安装
-prefix_log "checking homebrew installation status" $prefix
-if ! command -v brew &>/dev/null; then
- prefix_log "brew could not be found. Please install Homebrew." $prefix
- exit
-fi
-
-prefix_log "checking git installation status" $prefix
-# 检查 git 是否安装
-if ! command -v git &>/dev/null; then
- prefix_log "git could not be found. Please install git." $prefix
- exit
-fi
-
-prefix_log "autojump..." $prefix
-if brew list autojump &>/dev/null; then
- prefix_log "autojump is already installed." $prefix
-else
- brew install autojump
-fi
-# 检查内容并添加配置
-color_echo BLUE ">>> checking and append the init script for autojump to ~/.zshrc"
-append_to_zshrc "[ -f /opt/homebrew/etc/profile.d/autojump.sh ] && . /opt/homebrew/etc/profile.d/autojump.sh"
-color_echo GREEN "You can use 'j' and jump to whatever dir you've visited"
-
-prefix_log "bat..." $prefix
-# 使用 brew 安装 bat
-if brew list bat &>/dev/null; then
- prefix_log "bat is already installed." $prefix
-else
- brew install bat
-fi
-
-prefix_log "htop..." $prefix
-if brew list htop &>/dev/null; then
- prefix_log "htop is already installed." $prefix
-else
- brew install htop
-fi
-
-prefix_log "neofetch..." $prefix
-if brew list neofetch &>/dev/null; then
- prefix_log "neofetch is already installed." $prefix
-else
- brew install neofetch
-fi
-
-prefix_log "eza..., a replacement of ls" $prefix
-if brew list eza &>/dev/null; then
- prefix_log "eza is already installed." $prefix
-else
- brew install eza
- prefix_log "replace ls with eza" $prefix
-fi
-
-prefix_log "install atuin..." $prefix
-if brew list atuin &>/dev/null; then
- prefix_log "atuin is already installed." $prefix
-else
- brew install atuin
-fi
-# 检查内容并添加配置
-color_echo BLUE ">>> checking and append the init script for atuin o ~/.zshrc"
-append_to_zshrc 'eval "$(atuin init zsh --disable-ctrl-r)"'
-color_echo GREEN "You can use '↑(Arrow up)' to browser history with fuzzy search and using '/' to navigate."
-
-prefix_log "install fzf..." $prefix
-if brew list fzf &>/dev/null; then
- prefix_log "fzf is already installed." $prefix
-else
- brew install fzf
- brew install ripgrep
-fi
-# 检查内容并添加配置
-color_echo BLUE ">>> checking and append the init script for fzf o ~/.zshrc"
-# To install useful key bindings and fuzzy completion:
-$(brew --prefix)/opt/fzf/install
-append_to_zshrc "export FZF_CTRL_T_OPTS=\"--ansi --preview-window 'right:60%' --preview '[[ -d {} ]] && echo 'Directory' || bat --color=always --style=header,grid --line-range :300 {}'\"" "FZF_CTRL_T_OPTS"
-append_to_zshrc "export FZF_DEFAULT_COMMAND=\"rg --files --no-ignore --hidden --follow --glob '!.git/*'\"" "FZF_DEFAULT_COMMAND"
-
-prefix_log "install tree..." $prefix
-if brew list tree &>/dev/null; then
- prefix_log "tree is already installed." $prefix
-else
- brew install tree
-fi
-
-prefix_log "finished installation!" $prefix
-
-prefix_log "reload zsh" $prefix
-exec zsh
-
-color_echo GREEN "Done."
diff --git a/scripts/init/front-end.sh b/scripts/init/front-end.sh
deleted file mode 100755
index 085ff26..0000000
--- a/scripts/init/front-end.sh
+++ /dev/null
@@ -1,99 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-source $(pwd)/scripts/init/init-configs.sh
-
-# 获取最新的 Node.js LTS 版本
-color_echo YELLOW "[fe] fetching latest Node.js LTS version..."
-NODE_LTS_VERSION=$(curl -sf https://nodejs.org/dist/index.json | jq -r '[.[] | select(.lts != false)][0].version' | sed 's/^v//')
-
-if [ -z "$NODE_LTS_VERSION" ]; then
- color_echo YELLOW "[fe] failed to fetch latest Node.js LTS version, using default version 18"
- NODE_LTS_VERSION="18"
-else
- color_echo GREEN "[fe] Latest Node.js LTS version: $NODE_LTS_VERSION"
-fi
-
-
-# 询问用户选择 node 版本管理器
-echo "Which Node.js version manager would you prefer to use?"
-echo "1) Volta"
-echo "2) Fast Node Manager (fnm)"
-read -p "Please enter 1 or 2: " choice
-
-# 初始化安装命令变量
-if [ "$choice" = "1" ]; then
- INSTALL_CMD="volta install"
- NODE_INSTALL_CMD="volta install node@${NODE_LTS_VERSION#v}" # 移除版本号前的 'v'
-else
- INSTALL_CMD="npm install -g"
- NODE_INSTALL_CMD="fnm install ${NODE_LTS_VERSION} && fnm use ${NODE_LTS_VERSION}"
-fi
-
-case $choice in
- 1)
- # Volta 安装逻辑
- if command -v volta &> /dev/null; then
- color_echo GREEN "[fe] Volta is already installed"
- else
- color_echo GREEN "[fe] Volta could not be found"
- color_echo YELLOW "[fe] Installing Volta..."
- curl https://get.volta.sh | bash
- color_echo YELLOW "[fe] re-run this script to finish the installation"
- exec zsh
- exit 0
- fi
-
- color_echo GREEN "[fe] enable pnpm support for volta"
- append_to_zshrc "export VOLTA_FEATURE_PNPM=1" "VOLTA_FEATURE_PNPM" # support pnpm
- ;;
- 2)
- # fnm 安装逻辑
- if command -v fnm &> /dev/null; then
- color_echo GREEN "[fe] fnm is already installed"
- else
- color_echo GREEN "[fe] fnm could not be found"
- color_echo YELLOW "[fe] Installing fnm..."
- curl -fsSL https://fnm.vercel.app/install | bash
- color_echo YELLOW "[fe] re-run this script to finish the installation"
- exec zsh
- exit 0
- fi
- ;;
- *)
- color_echo RED "[fe] Invalid choice. Exiting..."
- exit 1
- ;;
-esac
-
-# 安装 pnpm
-color_echo YELLOW "[fe] Installing pnpm@7..."
-eval "$NODE_INSTALL_CMD"
-$INSTALL_CMD pnpm@7
-
-# 安装通用工具链
-if [[ $* == *--no-tools* ]]; then
- color_echo GREEN "[fe] Skipping tools chain installation"
-else
- color_echo YELLOW "[fe] Installing tools chain..."
- $INSTALL_CMD eslint prettier stylelint typescript@4.8 ts-node husky
-fi
-
-# git commitizen
-color_echo YELLOW "[fe] Checking git commitizen(git-cz)..."
-if ! command -v git-cz &> /dev/null; then
- color_echo YELLOW "[fe] Installing git commitizen(git-cz)..."
- $INSTALL_CMD git-cz
-else
- color_echo GREEN "[fe] git-cz is already installed"
-fi
-
-# init git-cz config
-if [ ! -f ~/.git-cz.json ]; then
- echo '{
- "disableEmoji": true
- }' > ~/.git-cz.json
-else
- color_echo GREEN "[fe] git-cz config already exists"
-fi
-
-color_echo GREEN "[fe] Done!"
diff --git a/scripts/init/init-configs.sh b/scripts/init/init-configs.sh
deleted file mode 100755
index aa53ce7..0000000
--- a/scripts/init/init-configs.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-
-# 创建配置文件目录
-mkdir -p $HOME/.config/suitup
-
-append_to() {
- if [ ! -f "$2" ] || ! grep -q "$1" "$2"; then
- echo "$1" >>"$2"
- fi
-}
-
-# 检查内容是否存在并插入 .zshrc
-# 两个参数用于关键字匹配,确定是否要新增配置
-append_to_zshrc() {
- if [ "$#" -eq 2 ]; then
- if [ ! -f "$HOME/.zshrc" ] || ! grep -q "$2" "$HOME/.zshrc"; then
- echo "$1" >>"$HOME/.zshrc"
- fi
- else
- append_to "$1" "$HOME/.zshrc"
- fi
-}
-
-# 定义 plugins 文件的路径
-plugin_file="$HOME/.config/suitup/plugins"
-# 检查 zshrc 文件中是否已经引用了 plugins 文件
-if ! grep -q "source $plugin_file" "$HOME/.zshrc"; then
- prefix_log "create plugins config to .config/zsh" $prefix
- # 如果没有引用,则创建 plugins 文件(如果尚未存在)
- [ -f "$plugin_file" ] || touch "$plugin_file"
- # 并在 zshrc 文件中引用它
- append_to_zshrc "source $plugin_file"
-fi
-
-# 定义 aliases 文件的路径
-aliases_file="$HOME/.config/suitup/aliases"
-# 检查 zshrc 文件中是否已经引用了 aliases 文件
-if ! grep -q "source $aliases_file" "$HOME/.zshrc"; then
- prefix_log "create aliases config to .config/zsh" $prefix
- # 如果没有引用,则创建 aliases 文件(如果尚未存在)
- [ -f "$aliases_file" ] || touch "$aliases_file"
- # 并在 zshrc 文件中引用它
- append_to_zshrc "source $aliases_file"
-fi
diff --git a/scripts/init/install-zsh-plugins.sh b/scripts/init/install-zsh-plugins.sh
deleted file mode 100755
index 9ca191e..0000000
--- a/scripts/init/install-zsh-plugins.sh
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-source $(pwd)/scripts/init/init-configs.sh
-
-prefix="zsh"
-plugin_file=$HOME/.config/suitup/plugins
-
-# 检查 .zshrc 文件是否存在
-if [ -f "$HOME/.zshrc" ]; then
- prefix_log ".zshrc file exists." $prefix
-else
- prefix_log ".zshrc file does not exist." $prefix
- exit 1
-fi
-
-# 通过 brew 安装 zplug
-if brew list zplug >/dev/null 2>&1; then
- prefix_log "zplug is already installed." $prefix
-else
- prefix_log "installing zplug..." $prefix
- brew install zplug
-fi
-
-# 将 zplug 配置添加到插件文件
-echo "source $(brew --prefix)/opt/zplug/init.zsh" > $plugin_file
-
-# 使用 zplug 安装插件
-prefix_log "installing plugins with zplug..." $prefix
-echo "zplug 'zsh-users/zsh-autosuggestions'" >> $plugin_file
-echo "zplug 'zsh-users/zsh-syntax-highlighting'" >> $plugin_file
-# echo "zplug 'junegunn/fzf', as:command, use:'bin/*'" >> $plugin_file
-
-# 在 .zshrc 中添加提示
-echo "echo \"[zplug] Updating zsh plugins...\"" >> $HOME/.zshrc
-echo "zplug install" >> $HOME/.zshrc
-
-# 让 plugins 生效
-echo "echo \"[zplug] Applying zsh plugins...\"" >> $HOME/.zshrc
-echo "zplug load" >> $HOME/.zshrc
-
-echo "echo \"[zplug] Completed!\"" >> $HOME/.zshrc
-
-prefix_log "here are plugins:" $prefix
-cat $plugin_file
-
-prefix_log "completed." $prefix
-
-# source .zshrc to apply changes
-exec zsh
diff --git a/scripts/init/ssh.sh b/scripts/init/ssh.sh
deleted file mode 100644
index 7f3619f..0000000
--- a/scripts/init/ssh.sh
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/bin/bash
-source $(pwd)/scripts/utils/log.sh
-
-prefix="ssh"
-
-# SSH key generation
-ssh_keygen_type="rsa"
-ssh_key_file="github_${ssh_keygen_type}"
-# check if the ssh key file already exists
-if [ -f ~/.ssh/$ssh_key_file ]; then
- prefix_log "SSH key already exists. Please delete it and run this script again." $prefix
- exit 1
-fi
-
-# Input email
-prefix_log "Generating SSH key..." $prefix
-read -p "Please enter your email: " email
-
-ssh-keygen -t ${ssh_keygen_type} -b 4096 -C "${email}" -f ~/.ssh/$ssh_key_file
-
-# Copy ssh key to clipboard
-if command -v xclip >/dev/null; then
- xclip -selection clipboard <~/.ssh/$ssh_key_file
-elif command -v pbcopy >/dev/null; then
- pbcopy <~/.ssh/$ssh_key_file.pub
-else
- prefix_log "Could not find any command to copy content to clipboard." $prefix
- prefix_log "Please install xclip or pbcopy." $prefix
- exit 1
-fi
-
-# Check ssh-client
-if command -v ssh-add >/dev/null; then
- ssh-add ~/.ssh/$ssh_key_file
-else
- prefix_log "Could not find the ssh client." $prefix
- prefix_log "Please install ssh client and then run this script again." $prefix
- exit 1
-fi
-
-prefix_log "SSH key has been generated and copied to clipboard. You can now add it to your GitHub account." $prefix
diff --git a/scripts/utils/log.sh b/scripts/utils/log.sh
deleted file mode 100755
index 0c5bbb9..0000000
--- a/scripts/utils/log.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-# 定义一些颜色代码
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-color_echo() {
- COLOR=$1
- shift
- echo -e "${!COLOR}$@${NC}"
-}
-
-prefix_log() {
- local prefix=${2:-log}
- color_echo BLUE "[$prefix] $1"
-}
diff --git a/snippets/zvm-fzf.snippet.sh b/snippets/zvm-fzf.snippet.sh
deleted file mode 100644
index bf65089..0000000
--- a/snippets/zvm-fzf.snippet.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-function zvm_post_init() {
- export FZF_DEFAULT_COMMAND='rg --files --no-ignore --hidden --follow --glob "!{**/node_modules/*,.git/*,*/tmp/*}"'
- eval "$(atuin init zsh)"
- eval "$(fzf --zsh)"
-}
-zvm_after_init_commands+=(zvm_post_init)
diff --git a/src/append.js b/src/append.js
new file mode 100644
index 0000000..132630b
--- /dev/null
+++ b/src/append.js
@@ -0,0 +1,186 @@
+import * as p from "@clack/prompts";
+import pc from "picocolors";
+import { existsSync, readFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { appendIfMissing, ensureDir, readFileSafe, copyFile } from "./utils/fs.js";
+import { CONFIGS_DIR, SUITUP_MARKER } from "./constants.js";
+
+const ZSHRC = join(homedir(), ".zshrc");
+const SUITUP_DIR = join(homedir(), ".config", "suitup");
+const ZSH_CONFIG = join(homedir(), ".config", "zsh");
+
+/** Appendable config blocks. */
+const BLOCKS = [
+ {
+ value: "suitup-aliases",
+ label: "Suitup aliases",
+ hint: "source ~/.config/suitup/aliases",
+ group: "Suitup Configs",
+ marker: "suitup/aliases",
+ apply() {
+ ensureDir(SUITUP_DIR);
+ copyFile(join(CONFIGS_DIR, "aliases"), join(SUITUP_DIR, "aliases"));
+ return appendIfMissing(
+ ZSHRC,
+ '\n# >>> suitup/aliases >>>\nsource_if_exists "$HOME/.config/suitup/aliases"\n# <<< suitup/aliases <<<\n',
+ "suitup/aliases"
+ );
+ },
+ },
+ {
+ value: "suitup-plugins",
+ label: "Zinit plugins",
+ hint: "source ~/.config/suitup/zinit-plugins",
+ group: "Suitup Configs",
+ marker: "suitup/zinit-plugins",
+ apply() {
+ ensureDir(SUITUP_DIR);
+ copyFile(join(CONFIGS_DIR, "zinit-plugins"), join(SUITUP_DIR, "zinit-plugins"));
+ return appendIfMissing(
+ ZSHRC,
+ '\n# >>> suitup/zinit-plugins >>>\nsource_if_exists "$HOME/.config/suitup/zinit-plugins"\n# <<< suitup/zinit-plugins <<<\n',
+ "suitup/zinit-plugins"
+ );
+ },
+ },
+ {
+ value: "tools-init",
+ label: "Tool initialization",
+ hint: "atuin, fzf, zoxide, fnm",
+ group: "Shell Enhancements",
+ marker: "suitup/tools-init",
+ apply() {
+ const block = [
+ "",
+ "# >>> suitup/tools-init >>>",
+ 'command -v atuin &>/dev/null && eval "$(atuin init zsh)"',
+ 'command -v fzf &>/dev/null && eval "$(fzf --zsh)"',
+ 'command -v zoxide &>/dev/null && eval "$(zoxide init zsh)"',
+ 'command -v fnm &>/dev/null && eval "$(fnm env --use-on-cd --version-file-strategy=recursive --shell zsh)"',
+ '[[ -s "$HOME/.bun/_bun" ]] && source "$HOME/.bun/_bun"',
+ "# <<< suitup/tools-init <<<",
+ "",
+ ].join("\n");
+ return appendIfMissing(ZSHRC, block, "suitup/tools-init");
+ },
+ },
+ {
+ value: "zsh-options",
+ label: "Zsh options",
+ hint: "history, completion, auto-cd",
+ group: "Shell Enhancements",
+ marker: "suitup/zsh-options",
+ apply() {
+ const content = readFileSync(join(CONFIGS_DIR, "core", "options.zsh"), "utf-8");
+ const block = `\n# >>> suitup/zsh-options >>>\n${content}\n# <<< suitup/zsh-options <<<\n`;
+ return appendIfMissing(ZSHRC, block, "suitup/zsh-options");
+ },
+ },
+ {
+ value: "env-vars",
+ label: "Environment variables",
+ hint: "BAT_THEME, ATUIN config",
+ group: "Shell Enhancements",
+ marker: "suitup/env",
+ apply() {
+ const content = readFileSync(join(CONFIGS_DIR, "core", "env.zsh"), "utf-8");
+ const block = `\n# >>> suitup/env >>>\n${content}\n# <<< suitup/env <<<\n`;
+ return appendIfMissing(ZSHRC, block, "suitup/env");
+ },
+ },
+ {
+ value: "perf",
+ label: "Startup performance monitor",
+ hint: "timing report on shell startup",
+ group: "Advanced",
+ marker: "suitup/perf",
+ apply() {
+ const content = readFileSync(join(CONFIGS_DIR, "core", "perf.zsh"), "utf-8");
+ const block = `\n# >>> suitup/perf >>>\n${content}\n# <<< suitup/perf <<<\n`;
+ return appendIfMissing(ZSHRC, block, "suitup/perf");
+ },
+ },
+ {
+ value: "fzf-config",
+ label: "FZF configuration",
+ hint: "fd-based search + preview",
+ group: "Advanced",
+ marker: "suitup/fzf-config",
+ apply() {
+ const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8");
+ const block = `\n# >>> suitup/fzf-config >>>\n${fzfContent.trim()}\n# <<< suitup/fzf-config <<<\n`;
+ return appendIfMissing(ZSHRC, block, "suitup/fzf-config");
+ },
+ },
+];
+
+/**
+ * Append mode — add recommended configs to an existing .zshrc.
+ */
+export async function runAppend() {
+ p.intro(pc.bgYellow(pc.black(" Suit up! — Append Mode ")));
+
+ if (!existsSync(ZSHRC)) {
+ p.log.warn("No ~/.zshrc found. Use `node src/cli.js setup` for a full setup instead.");
+ p.outro("Nothing to append to.");
+ return;
+ }
+
+ p.log.info(`Detected existing ${pc.cyan("~/.zshrc")}`);
+
+ // Check which blocks are already present
+ const existing = readFileSafe(ZSHRC);
+ const available = BLOCKS.filter((b) => !existing.includes(b.marker));
+
+ if (available.length === 0) {
+ p.log.success("All suitup configs are already present in .zshrc");
+ p.outro("Nothing to do.");
+ return;
+ }
+
+ // Also ensure source_if_exists helper is present
+ if (!existing.includes("source_if_exists")) {
+ appendIfMissing(
+ ZSHRC,
+ '\n# >>> suitup/helper >>>\nsource_if_exists() { [[ -f "$1" ]] && source "$1"; }\n# <<< suitup/helper <<<\n',
+ "suitup/helper"
+ );
+ }
+
+ // Group options for display
+ const groups = {};
+ for (const block of available) {
+ if (!groups[block.group]) groups[block.group] = [];
+ groups[block.group].push({
+ value: block.value,
+ label: block.label,
+ hint: block.hint,
+ });
+ }
+
+ const selected = await p.groupMultiselect({
+ message: "Select configs to append to .zshrc:",
+ options: groups,
+ });
+
+ if (p.isCancel(selected) || selected.length === 0) {
+ p.cancel("Nothing appended.");
+ return;
+ }
+
+ let appended = 0;
+ for (const value of selected) {
+ const block = BLOCKS.find((b) => b.value === value);
+ if (block && block.apply()) {
+ appended++;
+ p.log.success(`Appended: ${block.label}`);
+ }
+ }
+
+ p.outro(
+ appended > 0
+ ? `Appended ${appended} config(s). Run ${pc.cyan("exec zsh")} to reload.`
+ : "No changes made (configs already present)."
+ );
+}
diff --git a/src/clean.js b/src/clean.js
new file mode 100644
index 0000000..2401077
--- /dev/null
+++ b/src/clean.js
@@ -0,0 +1,40 @@
+import * as p from "@clack/prompts";
+import pc from "picocolors";
+import { existsSync, rmSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+/**
+ * Clean up suitup-managed configurations.
+ */
+export async function runClean() {
+ p.intro(pc.bgRed(pc.white(" Suit up! — Clean ")));
+
+ const confirm = await p.confirm({
+ message: "This will remove suitup config files. Continue?",
+ initialValue: false,
+ });
+
+ if (p.isCancel(confirm) || !confirm) {
+ p.cancel("Clean cancelled.");
+ return;
+ }
+
+ const targets = [
+ join(homedir(), ".config", "suitup"),
+ ];
+
+ for (const target of targets) {
+ if (existsSync(target)) {
+ rmSync(target, { recursive: true });
+ p.log.success(`Removed ${target.replace(homedir(), "~")}`);
+ }
+ }
+
+ p.log.info(
+ "Note: ~/.zshrc and ~/.config/zsh/ were not removed.\n" +
+ " Remove them manually if needed, or edit ~/.zshrc to remove suitup source lines."
+ );
+
+ p.outro("Clean complete.");
+}
diff --git a/src/cli.js b/src/cli.js
new file mode 100644
index 0000000..df66a04
--- /dev/null
+++ b/src/cli.js
@@ -0,0 +1,35 @@
+#!/usr/bin/env node
+
+import { checkNodeVersion } from "./utils/node-version.js";
+checkNodeVersion();
+
+import { runSetup } from "./setup.js";
+import { runAppend } from "./append.js";
+import { runVerify } from "./verify.js";
+import { runClean } from "./clean.js";
+
+const command = process.argv[2] || "setup";
+
+switch (command) {
+ case "setup":
+ await runSetup();
+ break;
+ case "append":
+ await runAppend();
+ break;
+ case "verify":
+ await runVerify();
+ break;
+ case "clean":
+ await runClean();
+ break;
+ default:
+ console.log(`Usage: node src/cli.js [setup|append|verify|clean]
+
+Commands:
+ setup Full interactive environment setup (default)
+ append Append recommended configs to existing .zshrc
+ verify Verify installation and config integrity
+ clean Remove suitup config files`);
+ process.exit(1);
+}
diff --git a/src/constants.js b/src/constants.js
new file mode 100644
index 0000000..2fc3131
--- /dev/null
+++ b/src/constants.js
@@ -0,0 +1,11 @@
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { dirname } from "node:path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+/** Absolute path to the configs/ directory in the suitup project. */
+export const CONFIGS_DIR = join(__dirname, "..", "configs");
+
+/** Suitup marker used for idempotent append operations. */
+export const SUITUP_MARKER = ">>> suitup";
diff --git a/src/setup.js b/src/setup.js
new file mode 100644
index 0000000..c9d0686
--- /dev/null
+++ b/src/setup.js
@@ -0,0 +1,162 @@
+import * as p from "@clack/prompts";
+import pc from "picocolors";
+import { bootstrap } from "./steps/bootstrap.js";
+import { installZinit, installOhMyZsh } from "./steps/plugin-manager.js";
+import { CLI_TOOLS, installCliTools } from "./steps/cli-tools.js";
+import { APPS, installApps } from "./steps/apps.js";
+import { installFrontendTools } from "./steps/frontend.js";
+import { setupSsh } from "./steps/ssh.js";
+import { setupVim } from "./steps/vim.js";
+import { setupAliases } from "./steps/aliases.js";
+import { cleanDock } from "./steps/dock.js";
+import { setupZshConfig, writeZshrc } from "./steps/zsh-config.js";
+
+/**
+ * Full interactive setup flow.
+ */
+export async function runSetup() {
+ p.intro(pc.bgCyan(pc.black(" Suit up! ")));
+
+ // --- Step 1: Select setup steps ---
+ const steps = await p.multiselect({
+ message: "Select setup steps:",
+ required: true,
+ options: [
+ { value: "bootstrap", label: "Bootstrap", hint: "Package manager + Zsh" },
+ { value: "zsh-config", label: "Zsh Config Structure", hint: "~/.config/zsh/" },
+ { value: "plugins", label: "Plugin Manager", hint: "zinit or Oh My Zsh" },
+ { value: "cli-tools", label: "CLI Tools", hint: "bat, eza, fzf, fd, zoxide, atuin..." },
+ { value: "apps", label: "GUI Apps", hint: "iTerm2, Raycast, VS Code..." },
+ { value: "frontend", label: "Frontend Tools", hint: "fnm, pnpm, git-cz" },
+ { value: "aliases", label: "Shell Aliases", hint: "git, eza, fzf shortcuts" },
+ { value: "ssh", label: "SSH Key", hint: "generate GitHub SSH key" },
+ { value: "vim", label: "Vim Config", hint: "basic vim setup" },
+ { value: "dock", label: "Dock Cleanup", hint: "clean macOS Dock" },
+ ],
+ initialValues: [
+ "bootstrap",
+ "zsh-config",
+ "plugins",
+ "cli-tools",
+ "apps",
+ "frontend",
+ "aliases",
+ ],
+ });
+
+ if (p.isCancel(steps)) {
+ p.cancel("Setup cancelled.");
+ process.exit(0);
+ }
+
+ // --- Step 2: Plugin manager choice (if selected) ---
+ let pluginManager = "zinit";
+ if (steps.includes("plugins")) {
+ const pmChoice = await p.select({
+ message: "Choose a plugin manager:",
+ options: [
+ { value: "zinit", label: "zinit", hint: "recommended — lightweight, fast" },
+ { value: "omz", label: "Oh My Zsh", hint: "feature-rich, popular" },
+ ],
+ });
+ if (p.isCancel(pmChoice)) {
+ p.cancel("Setup cancelled.");
+ process.exit(0);
+ }
+ pluginManager = pmChoice;
+ }
+
+ // --- Step 3: CLI tool selection (if selected) ---
+ let selectedTools = [];
+ if (steps.includes("cli-tools")) {
+ const toolChoice = await p.groupMultiselect({
+ message: "Select CLI tools to install:",
+ required: true,
+ options: {
+ Essentials: CLI_TOOLS.essentials,
+ "Shell Enhancement": CLI_TOOLS.shell,
+ Optional: CLI_TOOLS.optional,
+ },
+ });
+ if (p.isCancel(toolChoice)) {
+ p.cancel("Setup cancelled.");
+ process.exit(0);
+ }
+ selectedTools = toolChoice;
+ }
+
+ // --- Step 4: App selection (if selected) ---
+ let selectedApps = [];
+ if (steps.includes("apps")) {
+ const appChoice = await p.groupMultiselect({
+ message: "Select apps to install:",
+ options: {
+ Recommended: APPS.recommended,
+ Optional: APPS.optional,
+ Fonts: APPS.fonts,
+ },
+ });
+ if (p.isCancel(appChoice)) {
+ p.cancel("Setup cancelled.");
+ process.exit(0);
+ }
+ selectedApps = appChoice;
+ }
+
+ // --- Execute selected steps ---
+ p.log.step(pc.bold("Starting installation..."));
+
+ if (steps.includes("bootstrap")) {
+ await bootstrap();
+ }
+
+ if (steps.includes("zsh-config")) {
+ await setupZshConfig();
+ }
+
+ if (steps.includes("plugins")) {
+ if (pluginManager === "zinit") {
+ await installZinit();
+ } else {
+ await installOhMyZsh();
+ }
+ }
+
+ if (steps.includes("cli-tools")) {
+ await installCliTools(selectedTools);
+ }
+
+ if (steps.includes("apps")) {
+ await installApps(selectedApps);
+ }
+
+ if (steps.includes("frontend")) {
+ await installFrontendTools();
+ }
+
+ if (steps.includes("aliases")) {
+ await setupAliases();
+ }
+
+ if (steps.includes("ssh")) {
+ await setupSsh();
+ }
+
+ if (steps.includes("vim")) {
+ await setupVim();
+ }
+
+ if (steps.includes("dock")) {
+ await cleanDock();
+ }
+
+ // --- Write .zshrc ---
+ if (steps.includes("zsh-config")) {
+ await writeZshrc(pluginManager);
+ }
+
+ p.outro(
+ `Done! Run ${pc.cyan("exec zsh")} to reload your shell.\n` +
+ ` Problems? ${pc.underline(pc.cyan("https://github.com/ChangeHow/suitup/issues"))}`
+ );
+}
diff --git a/src/steps/aliases.js b/src/steps/aliases.js
new file mode 100644
index 0000000..4dc8475
--- /dev/null
+++ b/src/steps/aliases.js
@@ -0,0 +1,22 @@
+import * as p from "@clack/prompts";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { copyIfNotExists, ensureDir } from "../utils/fs.js";
+import { CONFIGS_DIR } from "../constants.js";
+
+/**
+ * Set up shell aliases.
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for testing)
+ */
+export async function setupAliases({ home } = {}) {
+ const base = home || homedir();
+ const dest = join(base, ".config", "suitup", "aliases");
+ ensureDir(join(base, ".config", "suitup"));
+ const copied = copyIfNotExists(join(CONFIGS_DIR, "aliases"), dest);
+ if (copied) {
+ p.log.success("Aliases written to ~/.config/suitup/aliases");
+ } else {
+ p.log.info("Aliases already exist at ~/.config/suitup/aliases, skipped");
+ }
+}
diff --git a/src/steps/apps.js b/src/steps/apps.js
new file mode 100644
index 0000000..45ccaee
--- /dev/null
+++ b/src/steps/apps.js
@@ -0,0 +1,41 @@
+import * as p from "@clack/prompts";
+import { brewInstalled, brewInstall } from "../utils/shell.js";
+
+/** All available GUI applications. */
+export const APPS = {
+ recommended: [
+ { value: "iterm2", label: "iTerm2", hint: "terminal emulator" },
+ { value: "raycast", label: "Raycast", hint: "launcher & productivity" },
+ { value: "visual-studio-code", label: "VS Code", hint: "code editor" },
+ ],
+ optional: [
+ { value: "itsycal", label: "Itsycal", hint: "menu bar calendar" },
+ { value: "postman", label: "Postman", hint: "API client" },
+ { value: "paper", label: "Pap.er", hint: "wallpaper app" },
+ ],
+ fonts: [
+ { value: "font-monaspace", label: "Monaspace", hint: "coding font by GitHub" },
+ { value: "font-space-mono", label: "Space Mono", hint: "monospace font" },
+ ],
+};
+
+/**
+ * Install selected GUI apps via Homebrew Cask.
+ * @param {string[]} apps - list of cask names
+ */
+export async function installApps(apps) {
+ for (const app of apps) {
+ if (brewInstalled(app)) {
+ p.log.success(`${app} is already installed`);
+ } else {
+ const s = p.spinner();
+ s.start(`Installing ${app}...`);
+ const ok = brewInstall(app, { cask: true });
+ if (ok) {
+ s.stop(`${app} installed`);
+ } else {
+ s.stop(`Failed to install ${app}`);
+ }
+ }
+ }
+}
diff --git a/src/steps/bootstrap.js b/src/steps/bootstrap.js
new file mode 100644
index 0000000..9731775
--- /dev/null
+++ b/src/steps/bootstrap.js
@@ -0,0 +1,132 @@
+import * as p from "@clack/prompts";
+import { commandExists, run, runStream } from "../utils/shell.js";
+
+function isBrewAvailable() {
+ return commandExists("brew");
+}
+
+async function ensureBrewOnMac() {
+ if (isBrewAvailable()) {
+ p.log.success("Homebrew is already installed");
+ return true;
+ }
+
+ const choice = await p.select({
+ message: "Homebrew not found. How do you want to continue?",
+ options: [
+ { value: "install", label: "Install Homebrew", hint: "recommended" },
+ { value: "skip", label: "Skip package manager", hint: "continue without brew" },
+ ],
+ initialValue: "install",
+ });
+
+ if (p.isCancel(choice) || choice === "skip") {
+ p.log.warn("Skipped Homebrew setup. Some later install steps may fail without brew.");
+ return false;
+ }
+
+ p.log.step("Installing Homebrew...");
+ await runStream('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"');
+ await runStream('(echo; echo \'eval "$(/opt/homebrew/bin/brew shellenv)"\') >> ~/.zprofile && eval "$(/opt/homebrew/bin/brew shellenv)"');
+ p.log.success("Homebrew installed");
+ return true;
+}
+
+function detectLinuxManagers() {
+ const managers = [];
+ if (commandExists("apt-get")) managers.push("apt-get");
+ if (commandExists("dnf")) managers.push("dnf");
+ if (commandExists("yum")) managers.push("yum");
+ if (isBrewAvailable()) managers.push("brew");
+ return managers;
+}
+
+async function chooseLinuxManager() {
+ const managers = detectLinuxManagers();
+ if (managers.length === 0) {
+ p.log.warn("No supported package manager detected. Skipping package manager setup.");
+ return "skip";
+ }
+
+ const labels = {
+ "apt-get": "apt-get",
+ dnf: "dnf",
+ yum: "yum",
+ brew: "brew",
+ };
+
+ const choice = await p.select({
+ message: "Choose package manager for bootstrap:",
+ options: [
+ ...managers.map((manager, idx) => ({
+ value: manager,
+ label: labels[manager],
+ hint: idx === 0 ? "recommended" : undefined,
+ })),
+ { value: "skip", label: "Skip package manager", hint: "manual install" },
+ ],
+ initialValue: managers[0],
+ });
+
+ if (p.isCancel(choice)) {
+ return "skip";
+ }
+ return choice;
+}
+
+async function installZshViaManager(manager) {
+ if (commandExists("zsh")) {
+ p.log.success("Zsh is already installed");
+ return;
+ }
+
+ if (manager === "skip") {
+ p.log.warn("Skipped Zsh install because package manager setup was skipped.");
+ return;
+ }
+
+ p.log.step("Installing Zsh...");
+ if (manager === "brew") {
+ await runStream("brew install zsh");
+ } else if (manager === "apt-get") {
+ await runStream("sudo apt-get update && sudo apt-get install -y zsh");
+ } else if (manager === "dnf") {
+ await runStream("sudo dnf install -y zsh");
+ } else if (manager === "yum") {
+ await runStream("sudo yum install -y zsh");
+ }
+ p.log.success("Zsh installed");
+}
+
+/**
+ * Install package manager baseline + Zsh.
+ */
+export async function bootstrap({ platform = process.platform } = {}) {
+ let manager = "skip";
+
+ if (platform === "darwin") {
+ const brewReady = await ensureBrewOnMac();
+ manager = brewReady ? "brew" : "skip";
+ } else if (platform === "linux") {
+ manager = await chooseLinuxManager();
+ } else {
+ p.log.warn(`Unsupported platform: ${platform}. Skipping package manager setup.`);
+ }
+
+ await installZshViaManager(manager);
+
+ // Set Zsh as default shell
+ try {
+ const currentShell = run("echo $SHELL", { quiet: true });
+ const zshPath = run("which zsh", { quiet: true });
+ if (currentShell !== zshPath) {
+ p.log.step("Setting Zsh as default shell...");
+ await runStream(`chsh -s "${zshPath}"`);
+ p.log.success("Zsh set as default shell");
+ } else {
+ p.log.success("Zsh is already the default shell");
+ }
+ } catch {
+ p.log.warn("Could not set Zsh as default shell automatically");
+ }
+}
diff --git a/src/steps/cli-tools.js b/src/steps/cli-tools.js
new file mode 100644
index 0000000..f86af86
--- /dev/null
+++ b/src/steps/cli-tools.js
@@ -0,0 +1,43 @@
+import * as p from "@clack/prompts";
+import { brewInstalled, brewInstall } from "../utils/shell.js";
+
+/** All available CLI tools with metadata. */
+export const CLI_TOOLS = {
+ essentials: [
+ { value: "bat", label: "bat", hint: "cat replacement with syntax highlighting" },
+ { value: "eza", label: "eza", hint: "modern ls replacement" },
+ { value: "fzf", label: "fzf", hint: "fuzzy finder" },
+ { value: "fd", label: "fd", hint: "find replacement" },
+ ],
+ shell: [
+ { value: "atuin", label: "atuin", hint: "shell history search" },
+ { value: "zoxide", label: "zoxide", hint: "smarter cd" },
+ { value: "ripgrep", label: "ripgrep", hint: "fast grep" },
+ ],
+ optional: [
+ { value: "htop", label: "htop", hint: "process viewer" },
+ { value: "tree", label: "tree", hint: "directory tree" },
+ { value: "jq", label: "jq", hint: "JSON processor" },
+ ],
+};
+
+/**
+ * Install selected CLI tools via Homebrew.
+ * @param {string[]} tools - list of brew formula names
+ */
+export async function installCliTools(tools) {
+ for (const tool of tools) {
+ if (brewInstalled(tool)) {
+ p.log.success(`${tool} is already installed`);
+ } else {
+ const s = p.spinner();
+ s.start(`Installing ${tool}...`);
+ const ok = brewInstall(tool);
+ if (ok) {
+ s.stop(`${tool} installed`);
+ } else {
+ s.stop(`Failed to install ${tool}`);
+ }
+ }
+ }
+}
diff --git a/src/steps/dock.js b/src/steps/dock.js
new file mode 100644
index 0000000..7120f77
--- /dev/null
+++ b/src/steps/dock.js
@@ -0,0 +1,30 @@
+import * as p from "@clack/prompts";
+import { runStream } from "../utils/shell.js";
+
+/**
+ * Clean up macOS Dock — remove default items and set preferences.
+ */
+export async function cleanDock() {
+ const shouldClean = await p.confirm({
+ message: "This will reset your Dock (remove all pinned apps and restart Dock). Continue?",
+ initialValue: false,
+ });
+
+ if (p.isCancel(shouldClean) || !shouldClean) {
+ p.log.info("Dock cleanup skipped");
+ return;
+ }
+
+ p.log.step("Cleaning macOS Dock...");
+ try {
+ await runStream(
+ 'defaults write com.apple.dock persistent-apps -array && ' +
+ 'defaults write com.apple.dock mineffect -string "genie" && ' +
+ 'defaults write com.apple.dock largesize -int 90 && ' +
+ 'killall Dock'
+ );
+ p.log.success("Dock cleaned and restarted");
+ } catch {
+ p.log.warn("Could not clean Dock");
+ }
+}
diff --git a/src/steps/frontend.js b/src/steps/frontend.js
new file mode 100644
index 0000000..d6210d7
--- /dev/null
+++ b/src/steps/frontend.js
@@ -0,0 +1,63 @@
+import * as p from "@clack/prompts";
+import { commandExists, run, runStream } from "../utils/shell.js";
+
+/**
+ * Install fnm (Fast Node Manager) and set up Node.js + pnpm.
+ */
+export async function installFrontendTools() {
+ // fnm
+ if (commandExists("fnm")) {
+ p.log.success("fnm is already installed");
+ } else {
+ p.log.step("Installing fnm...");
+ await runStream("curl -fsSL https://fnm.vercel.app/install | bash");
+ p.log.success("fnm installed");
+ }
+
+ // Fetch latest LTS version
+ let ltsVersion = "22";
+ 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
+ p.log.step(`Installing Node.js v${ltsVersion} via fnm...`);
+ try {
+ await runStream(`fnm install ${ltsVersion} && fnm use ${ltsVersion}`);
+ p.log.success(`Node.js v${ltsVersion} installed`);
+ } catch {
+ p.log.warn("Could not install Node.js — fnm may need a shell restart first");
+ }
+
+ // pnpm
+ if (commandExists("pnpm")) {
+ p.log.success("pnpm is already installed");
+ } else {
+ p.log.step("Installing pnpm...");
+ try {
+ await runStream("npm install -g pnpm");
+ p.log.success("pnpm installed");
+ } catch {
+ p.log.warn("Could not install pnpm — try running `npm install -g pnpm` manually");
+ }
+ }
+
+ // git-cz
+ if (commandExists("git-cz")) {
+ p.log.success("git-cz is already installed");
+ } else {
+ p.log.step("Installing git-cz...");
+ try {
+ await runStream("npm install -g git-cz");
+ p.log.success("git-cz installed");
+ } catch {
+ p.log.warn("Could not install git-cz");
+ }
+ }
+}
diff --git a/src/steps/plugin-manager.js b/src/steps/plugin-manager.js
new file mode 100644
index 0000000..2d6773a
--- /dev/null
+++ b/src/steps/plugin-manager.js
@@ -0,0 +1,84 @@
+import * as p from "@clack/prompts";
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { commandExists, run, runStream } from "../utils/shell.js";
+import { copyIfNotExists, ensureDir } from "../utils/fs.js";
+import { CONFIGS_DIR } from "../constants.js";
+
+/**
+ * Install zinit plugin manager and set up plugin config.
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for testing)
+ */
+export async function installZinit({ home } = {}) {
+ const base = home || homedir();
+ const zinitHome = join(
+ process.env.XDG_DATA_HOME || join(base, ".local", "share"),
+ "zinit",
+ "zinit.git"
+ );
+
+ if (existsSync(zinitHome)) {
+ p.log.success("zinit is already installed");
+ } else {
+ p.log.step("Installing zinit...");
+ await runStream(
+ `bash -c 'NO_INPUT=1 bash -c "$(curl --fail --show-error --silent --location https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)"'`
+ );
+ p.log.success("zinit installed");
+ }
+
+ // Copy plugin config (skip if already exists)
+ const dest = join(base, ".config", "suitup", "zinit-plugins");
+ ensureDir(join(base, ".config", "suitup"));
+ const copied = copyIfNotExists(join(CONFIGS_DIR, "zinit-plugins"), dest);
+ if (copied) {
+ p.log.success("zinit plugin config written to ~/.config/suitup/zinit-plugins");
+ } else {
+ p.log.info("zinit plugin config already exists, skipped");
+ }
+}
+
+/**
+ * Install Oh My Zsh (alternative plugin manager).
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for testing)
+ */
+export async function installOhMyZsh({ home } = {}) {
+ const base = home || homedir();
+ const omzDir = join(base, ".oh-my-zsh");
+
+ if (existsSync(omzDir)) {
+ p.log.success("Oh My Zsh is already installed");
+ } else {
+ p.log.step("Installing Oh My Zsh...");
+ await runStream(
+ 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended'
+ );
+ p.log.success("Oh My Zsh installed");
+ }
+
+ // Install p10k theme for OMZ
+ const p10kDir = join(omzDir, "custom", "themes", "powerlevel10k");
+ if (!existsSync(p10kDir)) {
+ p.log.step("Installing Powerlevel10k theme for Oh My Zsh...");
+ await runStream(
+ `git clone --depth=1 https://github.com/romkatv/powerlevel10k.git "${p10kDir}"`
+ );
+ }
+
+ // Install OMZ plugins
+ const pluginsDir = join(omzDir, "custom", "plugins");
+ for (const plugin of ["zsh-autosuggestions", "zsh-syntax-highlighting"]) {
+ const dir = join(pluginsDir, plugin);
+ if (!existsSync(dir)) {
+ p.log.step(`Installing ${plugin}...`);
+ await runStream(
+ `git clone https://github.com/zsh-users/${plugin} "${dir}"`
+ );
+ }
+ }
+
+ p.log.success("Oh My Zsh with plugins configured");
+}
diff --git a/src/steps/ssh.js b/src/steps/ssh.js
new file mode 100644
index 0000000..ead1905
--- /dev/null
+++ b/src/steps/ssh.js
@@ -0,0 +1,64 @@
+import * as p from "@clack/prompts";
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { runStream } from "../utils/shell.js";
+
+/**
+ * Generate an SSH key for GitHub.
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for testing)
+ */
+export async function setupSsh({ home } = {}) {
+ const base = home || homedir();
+ const keyFile = join(base, ".ssh", "github_rsa");
+
+ if (existsSync(keyFile)) {
+ p.log.success("SSH key already exists at ~/.ssh/github_rsa");
+ return;
+ }
+
+ const email = await p.text({
+ message: "Enter your email for the SSH key:",
+ placeholder: "you@example.com",
+ validate(value) {
+ if (!value) return "Email is required";
+ if (!value.includes("@")) return "Please enter a valid email";
+ },
+ });
+
+ if (p.isCancel(email)) return;
+
+ const passphrase = await p.password({
+ message: "Enter a passphrase for the SSH key (leave blank for no passphrase):",
+ validate(value) {
+ if (value.length > 0 && value.length < 8) {
+ return "Passphrase must be at least 8 characters, or leave blank";
+ }
+ },
+ });
+
+ if (p.isCancel(passphrase)) return;
+
+ p.log.step("Generating SSH key...");
+ await runStream(
+ `ssh-keygen -t rsa -b 4096 -C "${email}" -f "${keyFile}" -N "$SSH_KEYGEN_PASSPHRASE"`,
+ { env: { ...process.env, SSH_KEYGEN_PASSPHRASE: passphrase } }
+ );
+
+ // Copy public key to clipboard
+ try {
+ await runStream(`pbcopy < "${keyFile}.pub"`);
+ p.log.success("SSH key generated and public key copied to clipboard");
+ } catch {
+ p.log.success("SSH key generated at ~/.ssh/github_rsa");
+ p.log.info("Copy the public key manually: cat ~/.ssh/github_rsa.pub");
+ }
+
+ // Add to ssh-agent
+ try {
+ await runStream(`ssh-add "${keyFile}"`);
+ } catch {
+ p.log.warn("Could not add key to ssh-agent automatically");
+ }
+}
diff --git a/src/steps/vim.js b/src/steps/vim.js
new file mode 100644
index 0000000..5f36554
--- /dev/null
+++ b/src/steps/vim.js
@@ -0,0 +1,34 @@
+import * as p from "@clack/prompts";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { copyIfNotExists, appendIfMissing } from "../utils/fs.js";
+import { CONFIGS_DIR } from "../constants.js";
+
+/**
+ * Set up Vim configuration.
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for testing)
+ */
+export async function setupVim({ home } = {}) {
+ const base = home || homedir();
+ const suitupDir = join(base, ".config", "suitup");
+ const vimCfg = join(suitupDir, "config.vim");
+ const vimrc = join(base, ".vimrc");
+
+ // Copy vim config (skip if already exists)
+ const copied = copyIfNotExists(join(CONFIGS_DIR, "config.vim"), vimCfg);
+ if (copied) {
+ p.log.success("Vim config written to ~/.config/suitup/config.vim");
+ } else {
+ p.log.info("Vim config already exists, skipped");
+ }
+
+ // Ensure .vimrc sources it
+ const marker = "source " + vimCfg;
+ const appended = appendIfMissing(vimrc, `source ${vimCfg}\n`, marker);
+ if (appended) {
+ p.log.success(".vimrc configured to source suitup vim config");
+ } else {
+ p.log.info(".vimrc already sources suitup vim config, skipped");
+ }
+}
diff --git a/src/steps/zsh-config.js b/src/steps/zsh-config.js
new file mode 100644
index 0000000..3740f71
--- /dev/null
+++ b/src/steps/zsh-config.js
@@ -0,0 +1,89 @@
+import * as p from "@clack/prompts";
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { copyFile, copyIfNotExists, ensureDir, writeIfNotExists, readFileSafe, writeFile } from "../utils/fs.js";
+import { CONFIGS_DIR } from "../constants.js";
+
+/**
+ * Create the ~/.config/zsh/ directory structure with all config files.
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for testing)
+ */
+export async function setupZshConfig({ home } = {}) {
+ const base = home || homedir();
+ const zshConfig = join(base, ".config", "zsh");
+ const suitupConfig = join(base, ".config", "suitup");
+
+ // Ensure directories
+ for (const sub of ["core", "shared", "local"]) {
+ ensureDir(join(zshConfig, sub));
+ }
+ ensureDir(suitupConfig);
+
+ // Copy core configs (skip if already exist)
+ const coreFiles = ["perf.zsh", "env.zsh", "paths.zsh", "options.zsh"];
+ for (const file of coreFiles) {
+ const copied = copyIfNotExists(join(CONFIGS_DIR, "core", file), join(zshConfig, "core", file));
+ if (!copied) p.log.info(`Skipped core/${file} (already exists)`);
+ }
+
+ // Copy shared configs (skip if already exist)
+ const sharedFiles = ["tools.zsh", "fzf.zsh", "prompt.zsh"];
+ for (const file of sharedFiles) {
+ const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", file), join(zshConfig, "shared", file));
+ if (!copied) p.log.info(`Skipped shared/${file} (already exists)`);
+ }
+
+ // Create local placeholder (don't overwrite existing)
+ writeIfNotExists(
+ join(zshConfig, "local", "machine.zsh"),
+ readFileSafe(join(CONFIGS_DIR, "local", "machine.zsh"))
+ );
+
+ p.log.success("Zsh config structure created at ~/.config/zsh/");
+}
+
+/**
+ * Write the .zshrc file from template.
+ * @param {"zinit" | "omz"} pluginManager
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for testing)
+ */
+export async function writeZshrc(pluginManager = "zinit", { home } = {}) {
+ const base = home || homedir();
+ const zshrc = join(base, ".zshrc");
+ const templateName =
+ pluginManager === "omz" ? "zshrc-omz.template" : "zshrc.template";
+ const template = readFileSafe(join(CONFIGS_DIR, templateName));
+
+ if (existsSync(zshrc)) {
+ const existing = readFileSafe(zshrc);
+ if (existing.includes("Generated by suitup")) {
+ // Already a suitup-managed zshrc, overwrite
+ writeFile(zshrc, template);
+ p.log.success(".zshrc updated (suitup-managed)");
+ return;
+ }
+
+ // Existing non-suitup zshrc — ask before overwriting
+ const shouldOverwrite = await p.confirm({
+ message: "~/.zshrc exists and is not managed by suitup. Overwrite? (backup will be created)",
+ initialValue: false,
+ });
+
+ if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
+ p.log.warn(".zshrc left unchanged. Use 'append' mode to add configs to your existing .zshrc.");
+ return;
+ }
+
+ const backup = `${zshrc}.backup.${Date.now()}`;
+ const { default: fs } = await import("node:fs");
+ fs.copyFileSync(zshrc, backup);
+ writeFile(zshrc, template);
+ p.log.success(`.zshrc written (backup saved to ${backup})`);
+ } else {
+ writeFile(zshrc, template);
+ p.log.success(".zshrc created");
+ }
+}
diff --git a/src/utils/fs.js b/src/utils/fs.js
new file mode 100644
index 0000000..a437a67
--- /dev/null
+++ b/src/utils/fs.js
@@ -0,0 +1,99 @@
+import {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ writeFileSync,
+ appendFileSync,
+ copyFileSync,
+} from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { homedir } from "node:os";
+
+/**
+ * Ensure a directory exists (recursive).
+ */
+export function ensureDir(dirPath) {
+ if (!existsSync(dirPath)) {
+ mkdirSync(dirPath, { recursive: true });
+ }
+}
+
+/**
+ * Write a file only if it does not already exist.
+ * Returns true if the file was written, false if it already existed.
+ */
+export function writeIfNotExists(filePath, content) {
+ ensureDir(dirname(filePath));
+ if (existsSync(filePath)) return false;
+ writeFileSync(filePath, content, "utf-8");
+ return true;
+}
+
+/**
+ * Overwrite a file (creates parent dirs as needed).
+ */
+export function writeFile(filePath, content) {
+ ensureDir(dirname(filePath));
+ writeFileSync(filePath, content, "utf-8");
+}
+
+/**
+ * Read a file, returning empty string if it does not exist.
+ */
+export function readFileSafe(filePath) {
+ if (!existsSync(filePath)) return "";
+ return readFileSync(filePath, "utf-8");
+}
+
+/**
+ * Append a line to a file if the file does not already contain
+ * the given marker string. Uses suitup markers for idempotency.
+ *
+ * @param {string} filePath - target file
+ * @param {string} content - content block to append
+ * @param {string} marker - unique marker to detect duplicates
+ * @returns {boolean} true if content was appended
+ */
+export function appendIfMissing(filePath, content, marker) {
+ ensureDir(dirname(filePath));
+ const existing = readFileSafe(filePath);
+ if (existing.includes(marker)) return false;
+
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
+ appendFileSync(filePath, separator + content + "\n", "utf-8");
+ return true;
+}
+
+/**
+ * Copy a file, creating destination parent dirs as needed.
+ */
+export function copyFile(src, dest) {
+ ensureDir(dirname(dest));
+ copyFileSync(src, dest);
+}
+
+/**
+ * Copy a file only if the destination does not already exist.
+ * Returns true if copied, false if skipped.
+ */
+export function copyIfNotExists(src, dest) {
+ ensureDir(dirname(dest));
+ if (existsSync(dest)) return false;
+ copyFileSync(src, dest);
+ return true;
+}
+
+/**
+ * Resolve ~ and $HOME in a path.
+ */
+export function expandHome(p) {
+ return p.replace(/^~/, homedir()).replace(/\$HOME/g, homedir());
+}
+
+/**
+ * Get the suitup project root (where configs/ lives).
+ */
+export function projectRoot() {
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+}
diff --git a/src/utils/node-version.js b/src/utils/node-version.js
new file mode 100644
index 0000000..3836e0a
--- /dev/null
+++ b/src/utils/node-version.js
@@ -0,0 +1,33 @@
+/**
+ * Validates that the current Node.js runtime meets the minimum version
+ * required for native ESM support (including stable top-level await).
+ *
+ * Minimum supported version: Node.js 18.0.0 (Active LTS)
+ */
+
+const MIN_MAJOR = 18;
+
+/**
+ * Parse the major version number from a Node.js version string.
+ * @param {string} version - e.g. "v18.17.0"
+ * @returns {number}
+ */
+function parseMajor(version) {
+ return parseInt(version.replace(/^v/, "").split(".")[0], 10);
+}
+
+/**
+ * Check that Node.js is new enough to support native ESM.
+ * Exits the process with a helpful message if not.
+ */
+export function checkNodeVersion() {
+ const major = parseMajor(process.version);
+ if (major < MIN_MAJOR) {
+ console.error(
+ `suitup requires Node.js ${MIN_MAJOR} or later for native ESM support.\n` +
+ `You are running Node.js ${process.version}.\n` +
+ `Please upgrade: https://nodejs.org/en/download`
+ );
+ process.exit(1);
+ }
+}
diff --git a/src/utils/shell.js b/src/utils/shell.js
new file mode 100644
index 0000000..caac8f5
--- /dev/null
+++ b/src/utils/shell.js
@@ -0,0 +1,66 @@
+import { execSync, spawn } from "node:child_process";
+
+/**
+ * Run a shell command synchronously. Returns stdout as string.
+ * Throws on non-zero exit.
+ */
+export function run(cmd, opts = {}) {
+ return execSync(cmd, {
+ encoding: "utf-8",
+ stdio: opts.quiet ? "pipe" : ["pipe", "pipe", "pipe"],
+ ...opts,
+ }).trim();
+}
+
+/**
+ * Check whether a command exists in PATH.
+ */
+export function commandExists(name) {
+ try {
+ execSync(`command -v ${name}`, { stdio: "pipe" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check whether a Homebrew formula/cask is installed.
+ */
+export function brewInstalled(name) {
+ try {
+ execSync(`brew list ${name}`, { stdio: "pipe" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Install a Homebrew formula or cask. Returns true on success.
+ */
+export function brewInstall(name, { cask = false } = {}) {
+ const args = cask ? ["install", "--cask", name] : ["install", name];
+ try {
+ execSync(`brew ${args.join(" ")}`, { stdio: "inherit" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Run a shell command and stream output to stdout/stderr in real-time.
+ * Returns a promise that resolves with the exit code.
+ * @param {string} cmd
+ * @param {{ env?: Record }} [opts]
+ */
+export function runStream(cmd, opts = {}) {
+ return new Promise((resolve, reject) => {
+ const spawnOpts = { stdio: "inherit" };
+ if (opts.env) spawnOpts.env = opts.env;
+ const child = spawn("bash", ["-c", cmd], spawnOpts);
+ child.on("close", (code) => resolve(code));
+ child.on("error", reject);
+ });
+}
diff --git a/src/verify.js b/src/verify.js
new file mode 100644
index 0000000..62c0640
--- /dev/null
+++ b/src/verify.js
@@ -0,0 +1,142 @@
+import * as p from "@clack/prompts";
+import pc from "picocolors";
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { commandExists, run } from "./utils/shell.js";
+import { readFileSafe } from "./utils/fs.js";
+
+const HOME = homedir();
+
+/** Items to verify. */
+const CHECKS = {
+ configs: [
+ { path: ".zshrc", label: "~/.zshrc" },
+ { path: ".config/zsh/core/perf.zsh", label: "~/.config/zsh/core/perf.zsh" },
+ { path: ".config/zsh/core/env.zsh", label: "~/.config/zsh/core/env.zsh" },
+ { path: ".config/zsh/core/paths.zsh", label: "~/.config/zsh/core/paths.zsh" },
+ { path: ".config/zsh/core/options.zsh", label: "~/.config/zsh/core/options.zsh" },
+ { path: ".config/zsh/shared/tools.zsh", label: "~/.config/zsh/shared/tools.zsh" },
+ { path: ".config/zsh/shared/prompt.zsh", label: "~/.config/zsh/shared/prompt.zsh" },
+ { path: ".config/suitup/aliases", label: "~/.config/suitup/aliases" },
+ { path: ".config/suitup/zinit-plugins", label: "~/.config/suitup/zinit-plugins" },
+ ],
+ tools: [
+ { cmd: "brew", label: "Homebrew" },
+ { cmd: "zsh", label: "Zsh" },
+ { cmd: "bat", label: "bat" },
+ { cmd: "eza", label: "eza" },
+ { cmd: "fzf", label: "fzf" },
+ { cmd: "fd", label: "fd" },
+ { cmd: "atuin", label: "atuin" },
+ { cmd: "zoxide", label: "zoxide" },
+ { cmd: "rg", label: "ripgrep" },
+ { cmd: "fnm", label: "fnm" },
+ { cmd: "node", label: "Node.js" },
+ { cmd: "pnpm", label: "pnpm" },
+ ],
+};
+
+/**
+ * Run verification checks and print a report.
+ * @param {object} [opts]
+ * @param {string} [opts.home] - override home directory (for sandbox testing)
+ * @returns {{ configs: object[], tools: object[], syntax: object[] }}
+ */
+export async function runVerify(opts = {}) {
+ const home = opts.home || HOME;
+ const results = { configs: [], tools: [], syntax: [] };
+
+ p.intro(pc.bgGreen(pc.black(" Suit up! — Verify ")));
+
+ // --- Config files ---
+ p.log.step(pc.bold("Config files"));
+ for (const check of CHECKS.configs) {
+ const fullPath = join(home, check.path);
+ const exists = existsSync(fullPath);
+ results.configs.push({ ...check, ok: exists });
+ if (exists) {
+ p.log.success(`${check.label}`);
+ } else {
+ p.log.warn(`${check.label} — ${pc.yellow("missing")}`);
+ }
+ }
+
+ // --- CLI tools ---
+ p.log.step(pc.bold("CLI tools"));
+ for (const check of CHECKS.tools) {
+ const exists = commandExists(check.cmd);
+ results.tools.push({ ...check, ok: exists });
+ if (exists) {
+ p.log.success(`${check.label}`);
+ } else {
+ p.log.warn(`${check.label} — ${pc.yellow("not found")}`);
+ }
+ }
+
+ // --- Shell syntax ---
+ p.log.step(pc.bold("Shell syntax check"));
+ const zshFiles = [
+ join(home, ".zshrc"),
+ ...CHECKS.configs
+ .filter((c) => c.path.endsWith(".zsh"))
+ .map((c) => join(home, c.path)),
+ ];
+
+ for (const file of zshFiles) {
+ if (!existsSync(file)) continue;
+ try {
+ run(`zsh -n "${file}"`, { quiet: true });
+ const label = file.replace(home, "~");
+ results.syntax.push({ file: label, ok: true });
+ p.log.success(`${label} — syntax OK`);
+ } catch (err) {
+ const label = file.replace(home, "~");
+ results.syntax.push({ file: label, ok: false, error: err.message });
+ p.log.error(`${label} — ${pc.red("syntax error")}`);
+ }
+ }
+
+ // --- Summary ---
+ const totalChecks =
+ results.configs.length + results.tools.length + results.syntax.length;
+ const passed =
+ results.configs.filter((c) => c.ok).length +
+ results.tools.filter((c) => c.ok).length +
+ results.syntax.filter((c) => c.ok).length;
+
+ p.outro(`${passed}/${totalChecks} checks passed`);
+
+ return results;
+}
+
+/**
+ * Verify config files in a sandbox temp directory (for testing).
+ * Creates the expected structure and validates it.
+ */
+export function verifySandbox(sandboxHome) {
+ const results = { configs: [], syntax: [] };
+
+ for (const check of CHECKS.configs) {
+ const fullPath = join(sandboxHome, check.path);
+ const exists = existsSync(fullPath);
+ results.configs.push({ ...check, ok: exists });
+ }
+
+ // Syntax check on .zsh files
+ const zshFiles = CHECKS.configs
+ .filter((c) => c.path.endsWith(".zsh"))
+ .map((c) => join(sandboxHome, c.path))
+ .filter((f) => existsSync(f));
+
+ for (const file of zshFiles) {
+ try {
+ run(`zsh -n "${file}"`, { quiet: true });
+ results.syntax.push({ file, ok: true });
+ } catch (err) {
+ results.syntax.push({ file, ok: false, error: err.message });
+ }
+ }
+
+ return results;
+}
diff --git a/tests/append.test.js b/tests/append.test.js
new file mode 100644
index 0000000..748eda9
--- /dev/null
+++ b/tests/append.test.js
@@ -0,0 +1,158 @@
+import { describe, test, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { appendIfMissing, ensureDir, readFileSafe } from "../src/utils/fs.js";
+import { CONFIGS_DIR } from "../src/constants.js";
+
+describe("Append mode utilities", () => {
+ let sandbox;
+ let zshrcPath;
+
+ beforeEach(() => {
+ sandbox = mkdtempSync(join(tmpdir(), "suitup-test-"));
+ zshrcPath = join(sandbox, ".zshrc");
+ });
+
+ afterEach(() => {
+ rmSync(sandbox, { recursive: true, force: true });
+ });
+
+ test("appendIfMissing adds content when marker is absent", () => {
+ writeFileSync(zshrcPath, "# existing config\n", "utf-8");
+
+ const result = appendIfMissing(
+ zshrcPath,
+ '# >>> suitup/aliases >>>\nsource "$HOME/.config/suitup/aliases"\n# <<< suitup/aliases <<<',
+ "suitup/aliases"
+ );
+
+ expect(result).toBe(true);
+ const content = readFileSync(zshrcPath, "utf-8");
+ expect(content).toContain("suitup/aliases");
+ expect(content).toContain("# existing config");
+ });
+
+ test("appendIfMissing skips when marker already present", () => {
+ const original =
+ '# existing config\n# >>> suitup/aliases >>>\nsource "$HOME/.config/suitup/aliases"\n# <<< suitup/aliases <<<\n';
+ writeFileSync(zshrcPath, original, "utf-8");
+
+ const result = appendIfMissing(
+ zshrcPath,
+ '# >>> suitup/aliases >>>\nsource "$HOME/.config/suitup/aliases"\n# <<< suitup/aliases <<<',
+ "suitup/aliases"
+ );
+
+ expect(result).toBe(false);
+ const content = readFileSync(zshrcPath, "utf-8");
+ expect(content).toBe(original);
+ });
+
+ test("appendIfMissing is idempotent — double call does not duplicate", () => {
+ writeFileSync(zshrcPath, "# existing config\n", "utf-8");
+
+ appendIfMissing(zshrcPath, "# >>> suitup/test >>>\ntest\n# <<< suitup/test <<<", "suitup/test");
+ appendIfMissing(zshrcPath, "# >>> suitup/test >>>\ntest\n# <<< suitup/test <<<", "suitup/test");
+
+ const content = readFileSync(zshrcPath, "utf-8");
+ const matches = content.match(/suitup\/test/g);
+ // Should appear exactly 2 times (open marker + close marker), not 4
+ expect(matches.length).toBe(2);
+ });
+
+ test("appendIfMissing creates file if it does not exist", () => {
+ const newFile = join(sandbox, "subdir", "newfile");
+
+ const result = appendIfMissing(
+ newFile,
+ "# >>> suitup/new >>>\nnew content\n# <<< suitup/new <<<",
+ "suitup/new"
+ );
+
+ expect(result).toBe(true);
+ expect(existsSync(newFile)).toBe(true);
+ expect(readFileSync(newFile, "utf-8")).toContain("new content");
+ });
+
+ test("multiple different blocks can be appended independently", () => {
+ writeFileSync(zshrcPath, "# base\n", "utf-8");
+
+ appendIfMissing(zshrcPath, "# >>> suitup/a >>>\nblock-a\n# <<< suitup/a <<<", "suitup/a");
+ appendIfMissing(zshrcPath, "# >>> suitup/b >>>\nblock-b\n# <<< suitup/b <<<", "suitup/b");
+
+ const content = readFileSync(zshrcPath, "utf-8");
+ expect(content).toContain("block-a");
+ expect(content).toContain("block-b");
+ expect(content).toContain("# base");
+ });
+});
+
+describe("fzf-config block", () => {
+ let sandbox;
+ let zshrcPath;
+
+ beforeEach(() => {
+ sandbox = mkdtempSync(join(tmpdir(), "suitup-test-"));
+ zshrcPath = join(sandbox, ".zshrc");
+ });
+
+ afterEach(() => {
+ rmSync(sandbox, { recursive: true, force: true });
+ });
+
+ test("configs/shared/fzf.zsh exists as a standalone file", () => {
+ const fzfFile = join(CONFIGS_DIR, "shared", "fzf.zsh");
+ expect(existsSync(fzfFile)).toBe(true);
+ });
+
+ test("fzf.zsh contains expected FZF environment variables", () => {
+ const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8");
+ expect(fzfContent).toContain("FZF_DEFAULT_COMMAND");
+ expect(fzfContent).toContain("FZF_CTRL_T_COMMAND");
+ expect(fzfContent).toContain("FZF_CTRL_T_OPTS");
+ });
+
+ test("fzf.zsh does not contain tool-init cache helpers (those belong in tools.zsh)", () => {
+ const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8");
+ expect(fzfContent).not.toContain("_source_cached_tool_init");
+ expect(fzfContent).not.toContain("_zsh_tools_cache_dir");
+ });
+
+ test("tools.zsh no longer embeds FZF env vars directly", () => {
+ const toolsContent = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8");
+ expect(toolsContent).not.toContain("FZF_DEFAULT_COMMAND=");
+ expect(toolsContent).not.toContain("FZF_CTRL_T_OPTS=");
+ });
+
+ test("fzf-config block appends fzf.zsh content directly (no brittle string splitting)", () => {
+ writeFileSync(zshrcPath, "# base\n", "utf-8");
+
+ const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8").trim();
+ const block = `\n# >>> suitup/fzf-config >>>\n${fzfContent}\n# <<< suitup/fzf-config <<<\n`;
+
+ const result = appendIfMissing(zshrcPath, block, "suitup/fzf-config");
+
+ expect(result).toBe(true);
+ const written = readFileSync(zshrcPath, "utf-8");
+ expect(written).toContain("FZF_DEFAULT_COMMAND");
+ expect(written).toContain("FZF_CTRL_T_OPTS");
+ expect(written).toContain("# >>> suitup/fzf-config >>>");
+ expect(written).toContain("# <<< suitup/fzf-config <<<");
+ });
+
+ test("fzf-config block is idempotent — double append does not duplicate", () => {
+ writeFileSync(zshrcPath, "# base\n", "utf-8");
+
+ const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8").trim();
+ const block = `\n# >>> suitup/fzf-config >>>\n${fzfContent}\n# <<< suitup/fzf-config <<<\n`;
+
+ appendIfMissing(zshrcPath, block, "suitup/fzf-config");
+ appendIfMissing(zshrcPath, block, "suitup/fzf-config");
+
+ const written = readFileSync(zshrcPath, "utf-8");
+ const matches = written.match(/suitup\/fzf-config/g);
+ // Should appear exactly twice (open + close marker), not four
+ expect(matches.length).toBe(2);
+ });
+});
diff --git a/tests/apps.test.js b/tests/apps.test.js
new file mode 100644
index 0000000..e877bf5
--- /dev/null
+++ b/tests/apps.test.js
@@ -0,0 +1,50 @@
+import { describe, test, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+}));
+
+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 { installApps } from "../src/steps/apps.js";
+import { brewInstalled, brewInstall } from "../src/utils/shell.js";
+
+describe("apps step", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("skips apps that are already installed", async () => {
+ brewInstalled.mockReturnValue(true);
+
+ await installApps(["iterm2", "raycast"]);
+
+ expect(brewInstall).not.toHaveBeenCalled();
+ });
+
+ test("installs apps that are not present", async () => {
+ brewInstalled.mockReturnValue(false);
+
+ await installApps(["iterm2", "visual-studio-code"]);
+
+ expect(brewInstall).toHaveBeenCalledTimes(2);
+ expect(brewInstall).toHaveBeenCalledWith("iterm2", { cask: true });
+ expect(brewInstall).toHaveBeenCalledWith("visual-studio-code", { cask: true });
+ });
+
+ test("mixed: skips installed and installs missing", async () => {
+ brewInstalled.mockImplementation((name) => name === "iterm2");
+
+ await installApps(["iterm2", "raycast"]);
+
+ expect(brewInstall).toHaveBeenCalledTimes(1);
+ expect(brewInstall).toHaveBeenCalledWith("raycast", { cask: true });
+ });
+});
diff --git a/tests/bootstrap.test.js b/tests/bootstrap.test.js
new file mode 100644
index 0000000..3819e93
--- /dev/null
+++ b/tests/bootstrap.test.js
@@ -0,0 +1,133 @@
+import { describe, test, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+ select: vi.fn(),
+ confirm: vi.fn(),
+ text: vi.fn(),
+ isCancel: vi.fn(() => false),
+}));
+
+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 * as p from "@clack/prompts";
+import { bootstrap } from "../src/steps/bootstrap.js";
+import { commandExists, run, runStream } from "../src/utils/shell.js";
+
+describe("bootstrap step", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ p.select.mockResolvedValue("install");
+ });
+
+ test("skips Homebrew install when already present", async () => {
+ commandExists.mockImplementation((name) => name === "brew" || name === "zsh");
+ run.mockImplementation((cmd) => {
+ if (cmd.includes("echo $SHELL")) return "/bin/zsh";
+ if (cmd.includes("which zsh")) return "/bin/zsh";
+ return "";
+ });
+
+ await bootstrap({ platform: "darwin" });
+
+ expect(runStream).not.toHaveBeenCalled();
+ });
+
+ test("installs Homebrew when not present", async () => {
+ commandExists.mockImplementation((name) => {
+ if (name === "brew") return false;
+ if (name === "zsh") return true;
+ return false;
+ });
+ run.mockImplementation((cmd) => {
+ if (cmd.includes("echo $SHELL")) return "/bin/zsh";
+ if (cmd.includes("which zsh")) return "/bin/zsh";
+ return "";
+ });
+
+ await bootstrap({ platform: "darwin" });
+
+ expect(runStream).toHaveBeenCalledWith(expect.stringContaining("Homebrew/install"));
+ });
+
+ test("installs Zsh with brew on macOS", async () => {
+ commandExists.mockImplementation((name) => {
+ if (name === "brew") return true;
+ if (name === "zsh") return false;
+ return false;
+ });
+
+ await bootstrap({ platform: "darwin" });
+
+ expect(runStream).toHaveBeenCalledWith(expect.stringContaining("brew install zsh"));
+ });
+
+ test("supports Linux package manager selection", async () => {
+ p.select.mockResolvedValue("apt-get");
+ commandExists.mockImplementation((name) => {
+ if (name === "apt-get") return true;
+ if (name === "zsh") return false;
+ return false;
+ });
+ run.mockImplementation((cmd) => {
+ if (cmd.includes("echo $SHELL")) return "/bin/zsh";
+ if (cmd.includes("which zsh")) return "/bin/zsh";
+ return "";
+ });
+
+ await bootstrap({ platform: "linux" });
+
+ expect(runStream).toHaveBeenCalledWith(expect.stringContaining("apt-get install -y zsh"));
+ });
+
+ test("allows skipping package manager setup", async () => {
+ p.select.mockResolvedValue("skip");
+ commandExists.mockImplementation((name) => {
+ if (name === "brew") return false;
+ if (name === "zsh") return true;
+ return false;
+ });
+ run.mockImplementation((cmd) => {
+ if (cmd.includes("echo $SHELL")) return "/bin/zsh";
+ if (cmd.includes("which zsh")) return "/bin/zsh";
+ return "";
+ });
+
+ await bootstrap({ platform: "darwin" });
+
+ expect(runStream).not.toHaveBeenCalledWith(expect.stringContaining("Homebrew/install"));
+ });
+
+ test("sets Zsh as default shell when current shell differs", async () => {
+ commandExists.mockReturnValue(true);
+ run.mockImplementation((cmd) => {
+ if (cmd.includes("echo $SHELL")) return "/bin/bash";
+ if (cmd.includes("which zsh")) return "/bin/zsh";
+ return "";
+ });
+
+ await bootstrap({ platform: "darwin" });
+
+ expect(runStream).toHaveBeenCalledWith(expect.stringContaining("chsh"));
+ });
+
+ test("skips chsh when Zsh is already default shell", async () => {
+ commandExists.mockReturnValue(true);
+ run.mockImplementation((cmd) => {
+ if (cmd.includes("echo $SHELL")) return "/bin/zsh";
+ if (cmd.includes("which zsh")) return "/bin/zsh";
+ return "";
+ });
+
+ await bootstrap({ platform: "darwin" });
+
+ expect(runStream).not.toHaveBeenCalledWith(expect.stringContaining("chsh"));
+ });
+});
diff --git a/tests/cli-tools.test.js b/tests/cli-tools.test.js
new file mode 100644
index 0000000..618b783
--- /dev/null
+++ b/tests/cli-tools.test.js
@@ -0,0 +1,52 @@
+import { describe, test, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+}));
+
+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 { installCliTools } from "../src/steps/cli-tools.js";
+import { brewInstalled, brewInstall } from "../src/utils/shell.js";
+
+describe("cli-tools step", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("skips tools that are already installed", async () => {
+ brewInstalled.mockReturnValue(true);
+
+ await installCliTools(["bat", "eza", "fzf"]);
+
+ expect(brewInstall).not.toHaveBeenCalled();
+ });
+
+ test("installs tools that are not present", async () => {
+ brewInstalled.mockReturnValue(false);
+
+ await installCliTools(["bat", "eza"]);
+
+ expect(brewInstall).toHaveBeenCalledTimes(2);
+ expect(brewInstall).toHaveBeenCalledWith("bat");
+ expect(brewInstall).toHaveBeenCalledWith("eza");
+ });
+
+ test("mixed: skips installed and installs missing", async () => {
+ brewInstalled.mockImplementation((name) => name === "bat");
+
+ await installCliTools(["bat", "fzf", "fd"]);
+
+ expect(brewInstall).toHaveBeenCalledTimes(2);
+ expect(brewInstall).toHaveBeenCalledWith("fzf");
+ expect(brewInstall).toHaveBeenCalledWith("fd");
+ expect(brewInstall).not.toHaveBeenCalledWith("bat");
+ });
+});
diff --git a/tests/configs.test.js b/tests/configs.test.js
new file mode 100644
index 0000000..cd74016
--- /dev/null
+++ b/tests/configs.test.js
@@ -0,0 +1,234 @@
+import { describe, test, expect, beforeEach } from "vitest";
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { execSync } from "node:child_process";
+
+const CONFIGS_DIR = join(import.meta.dirname, "..", "configs");
+
+/** Private/company-specific patterns that must NOT appear in public configs. */
+const FORBIDDEN_PATTERNS = [
+ "kinit",
+ "BYTEDANCE",
+ "bytedance",
+ "bytenpm",
+ "bytenpx",
+ "bnpm.byted",
+ "chenzihao",
+ "changehow",
+ "opencode",
+ "occ-web",
+ "occ-edit",
+ "occ-update",
+ "OPENCODE_SERVER",
+ "API_KEY",
+ "ZHIPU",
+ "CONTEXT7",
+ "CODEX_API",
+ "sk-",
+ "ctx7sk-",
+];
+
+describe("Static config templates", () => {
+ test("aliases file exists and has content", () => {
+ const file = join(CONFIGS_DIR, "aliases");
+ expect(existsSync(file)).toBe(true);
+ const content = readFileSync(file, "utf-8");
+ expect(content.length).toBeGreaterThan(0);
+ // Should contain common aliases
+ expect(content).toContain("reload-zsh");
+ expect(content).toContain("gco");
+ expect(content).toContain("gph");
+ expect(content).toContain("eza");
+ expect(content).toContain("bat");
+ });
+
+ test("aliases file does not contain private/company content", () => {
+ const content = readFileSync(join(CONFIGS_DIR, "aliases"), "utf-8");
+ for (const pattern of FORBIDDEN_PATTERNS) {
+ expect(content).not.toContain(pattern);
+ }
+ });
+
+ test("zinit-plugins file exists and has correct content", () => {
+ const file = join(CONFIGS_DIR, "zinit-plugins");
+ expect(existsSync(file)).toBe(true);
+ const content = readFileSync(file, "utf-8");
+ expect(content).toContain("zinit");
+ expect(content).toContain("zsh-autosuggestions");
+ expect(content).toContain("zsh-syntax-highlighting");
+ // p10k is loaded last in prompt.zsh, not here
+ expect(content).not.toContain("powerlevel10k");
+ });
+
+ test("shared/prompt.zsh loads p10k theme last (after all other plugins)", () => {
+ const content = readFileSync(join(CONFIGS_DIR, "shared", "prompt.zsh"), "utf-8");
+ expect(content).toContain("powerlevel10k");
+ expect(content).toContain("zinit light romkatv/powerlevel10k");
+ expect(content).toContain("~/.p10k.zsh");
+ // p10k theme load must appear before .p10k.zsh source
+ const themeIdx = content.indexOf("zinit light romkatv/powerlevel10k");
+ const configIdx = content.indexOf("~/.p10k.zsh");
+ expect(themeIdx).toBeLessThan(configIdx);
+ });
+
+ test("config.vim file exists", () => {
+ expect(existsSync(join(CONFIGS_DIR, "config.vim"))).toBe(true);
+ });
+
+ test("core config files exist", () => {
+ for (const file of ["perf.zsh", "env.zsh", "paths.zsh", "options.zsh"]) {
+ expect(existsSync(join(CONFIGS_DIR, "core", file))).toBe(true);
+ }
+ });
+
+ test("shared config files exist", () => {
+ for (const file of ["tools.zsh", "prompt.zsh", "fzf.zsh"]) {
+ expect(existsSync(join(CONFIGS_DIR, "shared", file))).toBe(true);
+ }
+ });
+
+ test("zshrc templates exist", () => {
+ expect(existsSync(join(CONFIGS_DIR, "zshrc.template"))).toBe(true);
+ expect(existsSync(join(CONFIGS_DIR, "zshrc-omz.template"))).toBe(true);
+ });
+
+ test("core/paths.zsh does not contain private paths", () => {
+ const content = readFileSync(join(CONFIGS_DIR, "core", "paths.zsh"), "utf-8");
+ for (const pattern of FORBIDDEN_PATTERNS) {
+ expect(content).not.toContain(pattern);
+ }
+ });
+
+ test("core/env.zsh does not contain API keys", () => {
+ const content = readFileSync(join(CONFIGS_DIR, "core", "env.zsh"), "utf-8");
+ for (const pattern of FORBIDDEN_PATTERNS) {
+ expect(content).not.toContain(pattern);
+ }
+ });
+
+ test("shared/fzf.zsh uses fd instead of rg for FZF_DEFAULT_COMMAND", () => {
+ const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8");
+ expect(fzfContent).toContain("fd --type");
+ expect(fzfContent).toContain("FZF_DEFAULT_COMMAND");
+ expect(fzfContent).toContain("FZF_CTRL_T_COMMAND");
+ expect(fzfContent).toContain("FZF_CTRL_T_OPTS");
+ });
+
+ test("shared/tools.zsh contains tool-init helpers and sources fzf.zsh", () => {
+ const content = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8");
+ expect(content).toContain("fnm");
+ expect(content).toContain("atuin");
+ expect(content).toContain("zoxide");
+ expect(content).toContain("fzf.zsh");
+ });
+
+ test("all .zsh config files pass syntax check", () => {
+ const zshFiles = [
+ "core/perf.zsh",
+ "core/env.zsh",
+ "core/paths.zsh",
+ "core/options.zsh",
+ "shared/tools.zsh",
+ "shared/fzf.zsh",
+ "shared/prompt.zsh",
+ "local/machine.zsh",
+ ];
+
+ for (const file of zshFiles) {
+ const fullPath = join(CONFIGS_DIR, file);
+ if (!existsSync(fullPath)) continue;
+ expect(() => {
+ execSync(`zsh -n "${fullPath}"`, { stdio: "pipe" });
+ }).not.toThrow();
+ }
+ });
+
+ test("zshrc templates pass syntax check", () => {
+ for (const tmpl of ["zshrc.template", "zshrc-omz.template"]) {
+ const fullPath = join(CONFIGS_DIR, tmpl);
+ // These templates reference files that may not exist, so zsh -n may fail
+ // on source statements. We just check the file is valid UTF-8 and has content.
+ const content = readFileSync(fullPath, "utf-8");
+ expect(content.length).toBeGreaterThan(100);
+ expect(content).toContain("ZSH_CONFIG");
+ expect(content).toContain("source_if_exists");
+ }
+ });
+
+ test("no hardcoded home directory paths in any config", () => {
+ const allFiles = [
+ "aliases",
+ "zinit-plugins",
+ "core/perf.zsh",
+ "core/env.zsh",
+ "core/paths.zsh",
+ "core/options.zsh",
+ "shared/tools.zsh",
+ "shared/fzf.zsh",
+ "shared/prompt.zsh",
+ "zshrc.template",
+ "zshrc-omz.template",
+ ];
+
+ for (const file of allFiles) {
+ const fullPath = join(CONFIGS_DIR, file);
+ const content = readFileSync(fullPath, "utf-8");
+ // Should not contain any hardcoded /Users/username paths
+ expect(content).not.toMatch(/\/Users\/\w+/);
+ }
+ });
+});
+
+describe("perf.zsh EPOCHREALTIME guard", () => {
+ let perf;
+
+ beforeEach(() => {
+ perf = readFileSync(join(CONFIGS_DIR, "core", "perf.zsh"), "utf-8");
+ });
+
+ test("checks EPOCHREALTIME availability after zmodload attempt", () => {
+ expect(perf).toContain("${+EPOCHREALTIME}");
+ });
+
+ test("guard is placed after zmodload and before typeset declarations", () => {
+ const zmodloadIdx = perf.indexOf("zmodload zsh/datetime");
+ const guardIdx = perf.indexOf("${+EPOCHREALTIME}");
+ const typesetIdx = perf.indexOf("typeset -ga _zsh_stage_names");
+
+ expect(zmodloadIdx).toBeGreaterThan(-1);
+ expect(guardIdx).toBeGreaterThan(zmodloadIdx);
+ expect(guardIdx).toBeLessThan(typesetIdx);
+ });
+
+ test("defines no-op _stage stub in the fallback block", () => {
+ expect(perf).toMatch(/_stage\(\) \{ :; \}/);
+ });
+
+ test("defines no-op _zsh_report stub in the fallback block", () => {
+ expect(perf).toMatch(/_zsh_report\(\) \{ :; \}/);
+ });
+
+ test("uses return to skip the normal-path setup in the fallback block", () => {
+ // 'return' must appear inside the guard block (after the guard, before typeset declarations)
+ const guardIdx = perf.indexOf("${+EPOCHREALTIME}");
+ const typesetIdx = perf.indexOf("typeset -ga _zsh_stage_names");
+
+ // Use a regex search from the guard position to allow varying indentation
+ const afterGuard = perf.slice(guardIdx, typesetIdx);
+ expect(afterGuard).toMatch(/^\s*return\s*$/m);
+ });
+
+ test("normal path defines full _record_stage_duration function", () => {
+ expect(perf).toMatch(/_record_stage_duration\(\) \{/);
+ });
+
+ test("normal path _stage records stage names, not just a stub", () => {
+ // The full _stage definition assigns to _zsh_current_stage
+ expect(perf).toContain('_zsh_current_stage="$1"');
+ });
+
+ test("normal path _zsh_report outputs a timing table", () => {
+ expect(perf).toContain("┌──────────────────────────┐");
+ expect(perf).toContain("total");
+ });
+});
diff --git a/tests/dock.test.js b/tests/dock.test.js
new file mode 100644
index 0000000..79a86fd
--- /dev/null
+++ b/tests/dock.test.js
@@ -0,0 +1,57 @@
+import { describe, test, expect, vi, beforeEach } from "vitest";
+
+const { mockConfirm } = vi.hoisted(() => ({
+ mockConfirm: vi.fn(),
+}));
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+ confirm: mockConfirm,
+ isCancel: vi.fn(() => false),
+}));
+
+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 { cleanDock } from "../src/steps/dock.js";
+import { runStream } from "../src/utils/shell.js";
+
+describe("dock step", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("runs dock cleanup when user confirms", async () => {
+ mockConfirm.mockResolvedValue(true);
+
+ await cleanDock();
+
+ expect(runStream).toHaveBeenCalledWith(
+ expect.stringContaining("defaults write com.apple.dock")
+ );
+ });
+
+ test("skips dock cleanup when user declines", async () => {
+ mockConfirm.mockResolvedValue(false);
+
+ await cleanDock();
+
+ expect(runStream).not.toHaveBeenCalled();
+ });
+
+ test("skips dock cleanup when user cancels", async () => {
+ const { isCancel } = await import("@clack/prompts");
+ isCancel.mockReturnValue(true);
+ mockConfirm.mockResolvedValue(undefined);
+
+ await cleanDock();
+
+ expect(runStream).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/frontend.test.js b/tests/frontend.test.js
new file mode 100644
index 0000000..521ff62
--- /dev/null
+++ b/tests/frontend.test.js
@@ -0,0 +1,74 @@
+import { describe, test, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+}));
+
+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 { commandExists, run, runStream } from "../src/utils/shell.js";
+
+describe("frontend step", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Default: fetch LTS version fails gracefully
+ run.mockImplementation(() => { throw new Error("no curl"); });
+ });
+
+ test("skips all tools when already installed", async () => {
+ commandExists.mockReturnValue(true);
+
+ await installFrontendTools();
+
+ // runStream should only be called for Node install via fnm (always runs)
+ const calls = runStream.mock.calls.map((c) => c[0]);
+ // Should NOT contain curl fnm install or npm install -g
+ expect(calls.some((c) => c.includes("fnm.vercel.app"))).toBe(false);
+ expect(calls.some((c) => c.includes("npm install -g pnpm"))).toBe(false);
+ expect(calls.some((c) => c.includes("npm install -g git-cz"))).toBe(false);
+ });
+
+ test("installs fnm when not present", async () => {
+ commandExists.mockImplementation((name) => {
+ if (name === "fnm") return false;
+ return true; // pnpm and git-cz are installed
+ });
+
+ await installFrontendTools();
+
+ const calls = runStream.mock.calls.map((c) => c[0]);
+ expect(calls.some((c) => c.includes("fnm.vercel.app"))).toBe(true);
+ });
+
+ test("installs pnpm when not present", async () => {
+ commandExists.mockImplementation((name) => {
+ if (name === "pnpm") return false;
+ return true;
+ });
+
+ await installFrontendTools();
+
+ const calls = runStream.mock.calls.map((c) => c[0]);
+ expect(calls.some((c) => c.includes("npm install -g pnpm"))).toBe(true);
+ });
+
+ test("installs git-cz when not present", async () => {
+ commandExists.mockImplementation((name) => {
+ if (name === "git-cz") return false;
+ return true;
+ });
+
+ await installFrontendTools();
+
+ const calls = runStream.mock.calls.map((c) => c[0]);
+ expect(calls.some((c) => c.includes("npm install -g git-cz"))).toBe(true);
+ });
+});
diff --git a/tests/helpers.js b/tests/helpers.js
new file mode 100644
index 0000000..16d35bc
--- /dev/null
+++ b/tests/helpers.js
@@ -0,0 +1,17 @@
+import { mkdtempSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+/**
+ * Create a temporary sandbox directory for testing.
+ * Returns { path, cleanup } where cleanup removes the sandbox.
+ */
+export function createSandbox() {
+ const path = mkdtempSync(join(tmpdir(), "suitup-test-"));
+ return {
+ path,
+ cleanup() {
+ rmSync(path, { recursive: true, force: true });
+ },
+ };
+}
diff --git a/tests/perf.zsh b/tests/perf.zsh
new file mode 100644
index 0000000..5ac1db9
--- /dev/null
+++ b/tests/perf.zsh
@@ -0,0 +1,153 @@
+#!/usr/bin/env zsh
+# =============================================================================
+# Runtime behavioral tests for configs/core/perf.zsh
+#
+# Tests the EPOCHREALTIME guard: verifies that sourcing perf.zsh produces
+# functional no-op stubs when zsh/datetime is unavailable, and full timing
+# instrumentation when it is available.
+#
+# Usage:
+# zsh tests/perf.zsh
+#
+# Requires zsh. The "normal path" tests additionally require the zsh/datetime
+# module (present on macOS and most Linux distributions with a full zsh build).
+# =============================================================================
+
+typeset -i _pass=0 _fail=0
+
+PERF_ZSH="${${(%):-%x}:A:h}/../configs/core/perf.zsh"
+# ${(%):-%x} – expands to the path of the current script
+# :A – resolves symlinks to an absolute path
+# :h – strips the last component (filename), leaving the directory
+
+_ok() { print " ✓ $1"; (( _pass++ )); }
+_fail() { print " ✗ $1\n expected: '$2'\n actual: '$3'"; (( _fail++ )); }
+
+_assert_eq() {
+ local desc="$1" expected="$2" actual="$3"
+ [[ "$expected" == "$actual" ]] && _ok "$desc" || _fail "$desc" "$expected" "$actual"
+}
+
+_assert_ne() {
+ local desc="$1" unexpected="$2" actual="$3"
+ [[ "$unexpected" != "$actual" ]] && _ok "$desc" || _fail "$desc" "(not $unexpected)" "$actual"
+}
+
+_assert_contains() {
+ local desc="$1" needle="$2" haystack="$3"
+ [[ "$haystack" == *"$needle"* ]] && _ok "$desc" || _fail "$desc" "*$needle*" "$haystack"
+}
+
+# Each test case runs in a dedicated zsh subprocess for complete state isolation.
+# Results (PASS/FAIL lines) are printed to stdout and the subprocess exits
+# non-zero on assertion failure so the parent can tally counts.
+
+_run() {
+ local label="$1"
+ local script="$2"
+ local out rc
+ out=$(zsh -c "$script" 2>&1)
+ rc=$?
+ if (( rc == 0 )); then
+ _ok "$label"
+ else
+ _fail "$label" "exit 0" "exit $rc ($out)"
+ fi
+}
+
+# =============================================================================
+# 1. Fallback path: EPOCHREALTIME unavailable (zsh/datetime not loaded)
+# =============================================================================
+
+# Common preamble embedded into every fallback subprocess: override zmodload so
+# the zsh/datetime module is never loaded and EPOCHREALTIME stays unset.
+_FALLBACK_SETUP="
+ function zmodload { :; }
+ unset EPOCHREALTIME 2>/dev/null
+ source '$PERF_ZSH'
+"
+
+print "\nperf.zsh – fallback path (EPOCHREALTIME unavailable):"
+
+_run "_stage is a no-op (does not set _zsh_current_stage)" "
+ $_FALLBACK_SETUP
+ _stage 'test-stage'
+ [[ -z \"\${_zsh_current_stage:-}\" ]]
+"
+
+_run "_zsh_report is a no-op (produces no output)" "
+ $_FALLBACK_SETUP
+ out=\$(_zsh_report 2>&1)
+ [[ -z \"\$out\" ]]
+"
+
+_run "_zsh_stage_names array is not populated after _stage call" "
+ $_FALLBACK_SETUP
+ _stage 'x'
+ _stage 'y'
+ [[ -z \"\${_zsh_stage_names[*]:-}\" ]]
+"
+
+_run "repeated _stage calls do not raise errors" "
+ $_FALLBACK_SETUP
+ _stage 'a'; _stage 'b'; _stage 'c'
+ true
+"
+
+_run "repeated _zsh_report calls do not raise errors" "
+ $_FALLBACK_SETUP
+ _zsh_report; _zsh_report
+ true
+"
+
+# =============================================================================
+# 2. Normal path: EPOCHREALTIME available (zsh/datetime loaded successfully)
+# =============================================================================
+print "\nperf.zsh – normal path (EPOCHREALTIME available):"
+
+if ! zsh -c "zmodload zsh/datetime 2>/dev/null && (( \${+EPOCHREALTIME} ))" >/dev/null 2>&1; then
+ print " ⚠ zsh/datetime not available in this environment; skipping normal-path tests"
+else
+ _run "_stage sets _zsh_current_stage" "
+ source '$PERF_ZSH'
+ _stage 'env'
+ [[ \"\$_zsh_current_stage\" == 'env' ]]
+ "
+
+ _run "_stage records the previous stage name when transitioning" "
+ source '$PERF_ZSH'
+ _stage 'env'
+ _stage 'tools'
+ [[ \"\${_zsh_stage_names[1]}\" == 'env' ]]
+ "
+
+ _run "_stage records a non-zero duration for the previous stage" "
+ source '$PERF_ZSH'
+ _stage 'env'
+ sleep 0.01
+ _stage 'tools'
+ (( \${_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 is idempotent (second call produces no extra output)" "
+ source '$PERF_ZSH'
+ _stage 'env'
+ _zsh_report >/dev/null 2>&1
+ second=\$(_zsh_report 2>&1)
+ [[ -z \"\$second\" ]]
+ "
+fi
+
+# =============================================================================
+# Summary
+# =============================================================================
+print "\nResults: $_pass passed, $_fail failed."
+(( _fail == 0 ))
diff --git a/tests/plugin-manager.test.js b/tests/plugin-manager.test.js
new file mode 100644
index 0000000..2b8dbe0
--- /dev/null
+++ b/tests/plugin-manager.test.js
@@ -0,0 +1,92 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
+import { mkdirSync, existsSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { createSandbox } from "./helpers.js";
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+ confirm: vi.fn(),
+ text: vi.fn(),
+ isCancel: vi.fn(() => false),
+}));
+
+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 { installZinit, installOhMyZsh } from "../src/steps/plugin-manager.js";
+import { runStream } from "../src/utils/shell.js";
+
+describe("plugin-manager step", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sandbox = createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ test("installs zinit when directory does not exist", async () => {
+ await installZinit({ home: sandbox.path });
+
+ expect(runStream).toHaveBeenCalledWith(
+ expect.stringContaining("zinit")
+ );
+ // Plugin config should be written
+ expect(existsSync(join(sandbox.path, ".config", "suitup", "zinit-plugins"))).toBe(true);
+ });
+
+ test("skips zinit install when directory already exists", async () => {
+ // Create the zinit directory
+ mkdirSync(join(sandbox.path, ".local", "share", "zinit", "zinit.git"), { recursive: true });
+
+ await installZinit({ home: sandbox.path });
+
+ expect(runStream).not.toHaveBeenCalled();
+ });
+
+ test("skips zinit plugin config when already exists", async () => {
+ // Create zinit dir to skip install
+ mkdirSync(join(sandbox.path, ".local", "share", "zinit", "zinit.git"), { recursive: true });
+ // Pre-create the plugin config
+ mkdirSync(join(sandbox.path, ".config", "suitup"), { recursive: true });
+ writeFileSync(join(sandbox.path, ".config", "suitup", "zinit-plugins"), "existing", "utf-8");
+
+ await installZinit({ home: sandbox.path });
+
+ // Should not have been overwritten
+ const { readFileSync } = await import("node:fs");
+ const content = readFileSync(join(sandbox.path, ".config", "suitup", "zinit-plugins"), "utf-8");
+ expect(content).toBe("existing");
+ });
+
+ test("installs Oh My Zsh when directory does not exist", async () => {
+ await installOhMyZsh({ home: sandbox.path });
+
+ // Should call runStream for OMZ install + p10k + 2 plugins = 4 calls
+ expect(runStream).toHaveBeenCalledWith(
+ expect.stringContaining("ohmyzsh")
+ );
+ });
+
+ test("skips Oh My Zsh install when directory already exists", async () => {
+ // Create OMZ directory
+ mkdirSync(join(sandbox.path, ".oh-my-zsh"), { recursive: true });
+ // Still need p10k and plugins dirs to skip those too
+ mkdirSync(join(sandbox.path, ".oh-my-zsh", "custom", "themes", "powerlevel10k"), { recursive: true });
+ mkdirSync(join(sandbox.path, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions"), { recursive: true });
+ mkdirSync(join(sandbox.path, ".oh-my-zsh", "custom", "plugins", "zsh-syntax-highlighting"), { recursive: true });
+
+ await installOhMyZsh({ home: sandbox.path });
+
+ expect(runStream).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/setup.test.js b/tests/setup.test.js
new file mode 100644
index 0000000..e61f94b
--- /dev/null
+++ b/tests/setup.test.js
@@ -0,0 +1,154 @@
+import { describe, test, expect, beforeEach, afterEach } from "vitest";
+import {
+ mkdtempSync,
+ rmSync,
+ mkdirSync,
+ copyFileSync,
+ readFileSync,
+ existsSync,
+} from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+const CONFIGS_DIR = join(import.meta.dirname, "..", "configs");
+
+describe("Setup simulation in sandbox", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = mkdtempSync(join(tmpdir(), "suitup-setup-"));
+ });
+
+ afterEach(() => {
+ rmSync(sandbox, { recursive: true, force: true });
+ });
+
+ test("can create full config structure in sandbox", () => {
+ // Simulate what setupZshConfig does
+ const dirs = [
+ ".config/zsh/core",
+ ".config/zsh/shared",
+ ".config/zsh/local",
+ ".config/suitup",
+ ];
+ for (const dir of dirs) {
+ mkdirSync(join(sandbox, dir), { recursive: true });
+ }
+
+ // Copy core configs
+ for (const file of ["perf.zsh", "env.zsh", "paths.zsh", "options.zsh"]) {
+ copyFileSync(
+ join(CONFIGS_DIR, "core", file),
+ join(sandbox, ".config/zsh/core", file)
+ );
+ }
+
+ // Copy shared configs
+ for (const file of ["tools.zsh", "prompt.zsh"]) {
+ copyFileSync(
+ join(CONFIGS_DIR, "shared", file),
+ join(sandbox, ".config/zsh/shared", file)
+ );
+ }
+
+ // Copy suitup configs
+ copyFileSync(
+ join(CONFIGS_DIR, "aliases"),
+ join(sandbox, ".config/suitup/aliases")
+ );
+ copyFileSync(
+ join(CONFIGS_DIR, "zinit-plugins"),
+ join(sandbox, ".config/suitup/zinit-plugins")
+ );
+
+ // Copy .zshrc
+ copyFileSync(
+ join(CONFIGS_DIR, "zshrc.template"),
+ join(sandbox, ".zshrc")
+ );
+
+ // Verify all expected files exist
+ const expectedFiles = [
+ ".zshrc",
+ ".config/zsh/core/perf.zsh",
+ ".config/zsh/core/env.zsh",
+ ".config/zsh/core/paths.zsh",
+ ".config/zsh/core/options.zsh",
+ ".config/zsh/shared/tools.zsh",
+ ".config/zsh/shared/prompt.zsh",
+ ".config/suitup/aliases",
+ ".config/suitup/zinit-plugins",
+ ];
+
+ for (const file of expectedFiles) {
+ expect(existsSync(join(sandbox, file))).toBe(true);
+ }
+ });
+
+ test("zshrc template has correct structure", () => {
+ const content = readFileSync(join(CONFIGS_DIR, "zshrc.template"), "utf-8");
+
+ // Should have the orchestration structure
+ expect(content).toContain('export ZSH_CONFIG="$HOME/.config/zsh"');
+ expect(content).toContain("source_if_exists");
+ expect(content).toContain("core/perf.zsh");
+ expect(content).toContain("core/env.zsh");
+ expect(content).toContain("core/paths.zsh");
+ expect(content).toContain("core/options.zsh");
+ expect(content).toContain("shared/tools.zsh");
+ expect(content).toContain("zinit");
+ expect(content).toContain("suitup/zinit-plugins");
+ expect(content).toContain("suitup/aliases");
+ expect(content).toContain("shared/prompt.zsh");
+ expect(content).toContain("_zsh_report");
+
+ // prompt.zsh (which loads p10k last) must come after zinit-plugins
+ const pluginsIdx = content.indexOf("suitup/zinit-plugins");
+ const promptIdx = content.indexOf("shared/prompt.zsh");
+ const reportIdx = content.indexOf("_zsh_report");
+ expect(pluginsIdx).toBeLessThan(promptIdx);
+ expect(promptIdx).toBeLessThan(reportIdx);
+ });
+
+ test("omz template has Oh My Zsh structure", () => {
+ const content = readFileSync(
+ join(CONFIGS_DIR, "zshrc-omz.template"),
+ "utf-8"
+ );
+
+ expect(content).toContain('export ZSH="$HOME/.oh-my-zsh"');
+ expect(content).toContain("oh-my-zsh.sh");
+ expect(content).toContain("powerlevel10k");
+ expect(content).toContain("plugins=(");
+ // OMZ template should NOT have zinit references
+ expect(content).not.toContain("zinit.zsh");
+ });
+
+ test("aliases file uses $HOME or ~ instead of hardcoded paths", () => {
+ const content = readFileSync(join(CONFIGS_DIR, "aliases"), "utf-8");
+
+ // Should use ~ or $HOME, not /Users/something
+ expect(content).toContain("~/.zshrc");
+ expect(content).not.toMatch(/\/Users\/\w+/);
+ });
+
+ test("tools.zsh uses fnm instead of volta", () => {
+ const content = readFileSync(
+ join(CONFIGS_DIR, "shared", "tools.zsh"),
+ "utf-8"
+ );
+
+ expect(content).toContain("fnm");
+ expect(content).not.toContain("volta");
+ });
+
+ test("tools.zsh uses zoxide instead of autojump", () => {
+ const content = readFileSync(
+ join(CONFIGS_DIR, "shared", "tools.zsh"),
+ "utf-8"
+ );
+
+ expect(content).toContain("zoxide");
+ expect(content).not.toContain("autojump");
+ });
+});
diff --git a/tests/ssh.test.js b/tests/ssh.test.js
new file mode 100644
index 0000000..b7a54d8
--- /dev/null
+++ b/tests/ssh.test.js
@@ -0,0 +1,113 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
+import { mkdirSync, existsSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { createSandbox } from "./helpers.js";
+
+const { mockText, mockPassword } = vi.hoisted(() => ({
+ mockText: vi.fn(),
+ mockPassword: vi.fn(),
+}));
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+ confirm: vi.fn(),
+ text: mockText,
+ password: mockPassword,
+ isCancel: vi.fn(() => false),
+}));
+
+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 { setupSsh } from "../src/steps/ssh.js";
+import { runStream } from "../src/utils/shell.js";
+
+describe("ssh step", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sandbox = createSandbox();
+ mockText.mockResolvedValue("test@example.com");
+ mockPassword.mockResolvedValue("");
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ test("skips when SSH key already exists", async () => {
+ // Create the key file
+ mkdirSync(join(sandbox.path, ".ssh"), { recursive: true });
+ writeFileSync(join(sandbox.path, ".ssh", "github_rsa"), "key-content", "utf-8");
+
+ await setupSsh({ home: sandbox.path });
+
+ expect(runStream).not.toHaveBeenCalled();
+ expect(mockText).not.toHaveBeenCalled();
+ });
+
+ test("generates SSH key when not present", async () => {
+ await setupSsh({ home: sandbox.path });
+
+ expect(mockText).toHaveBeenCalled();
+ const sshKeygenCall = runStream.mock.calls.find((c) => c[0].includes("ssh-keygen"));
+ expect(sshKeygenCall).toBeDefined();
+ expect(sshKeygenCall[0]).toContain("test@example.com");
+ });
+
+ test("uses sandbox path for key file location", async () => {
+ await setupSsh({ home: sandbox.path });
+
+ const sshKeygenCall = runStream.mock.calls.find((c) => c[0].includes("ssh-keygen"));
+ expect(sshKeygenCall).toBeDefined();
+ expect(sshKeygenCall[0]).toContain(sandbox.path);
+ });
+
+ test("generates key without passphrase when left blank", async () => {
+ mockPassword.mockResolvedValue("");
+
+ await setupSsh({ home: sandbox.path });
+
+ const sshKeygenCall = runStream.mock.calls.find((c) => c[0].includes("ssh-keygen"));
+ expect(sshKeygenCall).toBeDefined();
+ expect(sshKeygenCall[0]).toContain('-N "$SSH_KEYGEN_PASSPHRASE"');
+ expect(sshKeygenCall[1]).toEqual({ env: expect.objectContaining({ SSH_KEYGEN_PASSPHRASE: "" }) });
+ });
+
+ test("generates key with passphrase when provided", async () => {
+ mockPassword.mockResolvedValue("s3cr3tPass");
+
+ await setupSsh({ home: sandbox.path });
+
+ const sshKeygenCall = runStream.mock.calls.find((c) => c[0].includes("ssh-keygen"));
+ expect(sshKeygenCall).toBeDefined();
+ expect(sshKeygenCall[0]).toContain('-N "$SSH_KEYGEN_PASSPHRASE"');
+ expect(sshKeygenCall[1]).toEqual({ env: expect.objectContaining({ SSH_KEYGEN_PASSPHRASE: "s3cr3tPass" }) });
+ });
+
+ test("aborts when passphrase prompt is cancelled", async () => {
+ const { isCancel } = await import("@clack/prompts");
+ isCancel.mockImplementationOnce(() => false); // email not cancelled
+ isCancel.mockImplementationOnce(() => true); // passphrase is cancelled
+ mockPassword.mockResolvedValue("s3cr3tPass");
+
+ await setupSsh({ home: sandbox.path });
+
+ expect(runStream).not.toHaveBeenCalled();
+ });
+
+ test("prompts for passphrase after email", async () => {
+ await setupSsh({ home: sandbox.path });
+
+ expect(mockPassword).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining("passphrase") })
+ );
+ });
+});
diff --git a/tests/verify.test.js b/tests/verify.test.js
new file mode 100644
index 0000000..92c4df6
--- /dev/null
+++ b/tests/verify.test.js
@@ -0,0 +1,108 @@
+import { describe, test, expect, beforeEach, afterEach } from "vitest";
+import {
+ mkdtempSync,
+ writeFileSync,
+ rmSync,
+ mkdirSync,
+ copyFileSync,
+ existsSync,
+} from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { execSync } from "node:child_process";
+import { verifySandbox } from "../src/verify.js";
+
+const CONFIGS_DIR = join(import.meta.dirname, "..", "configs");
+
+describe("Verify in sandbox", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = mkdtempSync(join(tmpdir(), "suitup-verify-"));
+ });
+
+ afterEach(() => {
+ rmSync(sandbox, { recursive: true, force: true });
+ });
+
+ test("empty sandbox reports all configs missing", () => {
+ const results = verifySandbox(sandbox);
+ expect(results.configs.every((c) => !c.ok)).toBe(true);
+ });
+
+ test("populated sandbox reports all configs present", () => {
+ // Create the expected structure
+ const dirs = [
+ ".config/zsh/core",
+ ".config/zsh/shared",
+ ".config/suitup",
+ ];
+ for (const dir of dirs) {
+ mkdirSync(join(sandbox, dir), { recursive: true });
+ }
+
+ // Copy config files
+ const fileMappings = [
+ ["core/perf.zsh", ".config/zsh/core/perf.zsh"],
+ ["core/env.zsh", ".config/zsh/core/env.zsh"],
+ ["core/paths.zsh", ".config/zsh/core/paths.zsh"],
+ ["core/options.zsh", ".config/zsh/core/options.zsh"],
+ ["shared/tools.zsh", ".config/zsh/shared/tools.zsh"],
+ ["shared/prompt.zsh", ".config/zsh/shared/prompt.zsh"],
+ ["aliases", ".config/suitup/aliases"],
+ ["zinit-plugins", ".config/suitup/zinit-plugins"],
+ ];
+
+ for (const [src, dest] of fileMappings) {
+ copyFileSync(join(CONFIGS_DIR, src), join(sandbox, dest));
+ }
+
+ // Create .zshrc
+ copyFileSync(join(CONFIGS_DIR, "zshrc.template"), join(sandbox, ".zshrc"));
+
+ const results = verifySandbox(sandbox);
+ expect(results.configs.every((c) => c.ok)).toBe(true);
+ });
+
+ test("zsh syntax check passes for all config files", () => {
+ const dirs = [
+ ".config/zsh/core",
+ ".config/zsh/shared",
+ ];
+ for (const dir of dirs) {
+ mkdirSync(join(sandbox, dir), { recursive: true });
+ }
+
+ const zshFiles = [
+ ["core/perf.zsh", ".config/zsh/core/perf.zsh"],
+ ["core/env.zsh", ".config/zsh/core/env.zsh"],
+ ["core/paths.zsh", ".config/zsh/core/paths.zsh"],
+ ["core/options.zsh", ".config/zsh/core/options.zsh"],
+ ["shared/prompt.zsh", ".config/zsh/shared/prompt.zsh"],
+ ];
+
+ for (const [src, dest] of zshFiles) {
+ copyFileSync(join(CONFIGS_DIR, src), join(sandbox, dest));
+ }
+
+ const results = verifySandbox(sandbox);
+ expect(results.syntax.length).toBeGreaterThan(0);
+ expect(results.syntax.every((s) => s.ok)).toBe(true);
+ });
+
+ test("syntax check detects invalid zsh file", () => {
+ mkdirSync(join(sandbox, ".config/zsh/core"), { recursive: true });
+
+ // Write an invalid zsh file
+ writeFileSync(
+ join(sandbox, ".config/zsh/core/env.zsh"),
+ 'if [[ "unclosed\n',
+ "utf-8"
+ );
+
+ const results = verifySandbox(sandbox);
+ const envCheck = results.syntax.find((s) => s.file.includes("env.zsh"));
+ expect(envCheck).toBeDefined();
+ expect(envCheck.ok).toBe(false);
+ });
+});
diff --git a/tests/zsh-config-steps.test.js b/tests/zsh-config-steps.test.js
new file mode 100644
index 0000000..7095e62
--- /dev/null
+++ b/tests/zsh-config-steps.test.js
@@ -0,0 +1,203 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { createSandbox } from "./helpers.js";
+
+const { mockConfirm } = vi.hoisted(() => ({
+ mockConfirm: vi.fn(),
+}));
+
+vi.mock("@clack/prompts", () => ({
+ log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+ confirm: mockConfirm,
+ text: vi.fn(),
+ isCancel: vi.fn(() => false),
+}));
+
+import { setupZshConfig, writeZshrc } from "../src/steps/zsh-config.js";
+import { setupAliases } from "../src/steps/aliases.js";
+import { setupVim } from "../src/steps/vim.js";
+
+describe("zsh-config step", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sandbox = createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ test("creates full directory structure in empty sandbox", async () => {
+ await setupZshConfig({ home: sandbox.path });
+
+ expect(existsSync(join(sandbox.path, ".config", "zsh", "core", "perf.zsh"))).toBe(true);
+ expect(existsSync(join(sandbox.path, ".config", "zsh", "core", "env.zsh"))).toBe(true);
+ expect(existsSync(join(sandbox.path, ".config", "zsh", "core", "paths.zsh"))).toBe(true);
+ expect(existsSync(join(sandbox.path, ".config", "zsh", "core", "options.zsh"))).toBe(true);
+ expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "tools.zsh"))).toBe(true);
+ expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "fzf.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);
+ });
+
+ test("copies the optimized startup config files", async () => {
+ await setupZshConfig({ home: sandbox.path });
+
+ const perf = readFileSync(join(sandbox.path, ".config", "zsh", "core", "perf.zsh"), "utf-8");
+ const tools = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "tools.zsh"), "utf-8");
+ const fzf = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "fzf.zsh"), "utf-8");
+
+ expect(perf).toContain("EPOCHREALTIME");
+ expect(perf).toContain("_record_stage_duration");
+ expect(tools).toContain("_source_cached_tool_init");
+ expect(tools).toContain("$_zsh_tools_cache_dir");
+ expect(fzf).toContain("FZF_DEFAULT_COMMAND");
+ expect(fzf).toContain("FZF_CTRL_T_OPTS");
+ });
+
+ test("skips existing config files without overwriting", async () => {
+ // Pre-create a core file with custom content
+ mkdirSync(join(sandbox.path, ".config", "zsh", "core"), { recursive: true });
+ writeFileSync(join(sandbox.path, ".config", "zsh", "core", "env.zsh"), "# my custom env", "utf-8");
+
+ await setupZshConfig({ home: sandbox.path });
+
+ // env.zsh should retain custom content
+ const content = readFileSync(join(sandbox.path, ".config", "zsh", "core", "env.zsh"), "utf-8");
+ expect(content).toBe("# my custom env");
+
+ // Other files should still be created
+ expect(existsSync(join(sandbox.path, ".config", "zsh", "core", "perf.zsh"))).toBe(true);
+ });
+
+ test("writeZshrc creates .zshrc in empty sandbox", async () => {
+ await writeZshrc("zinit", { home: sandbox.path });
+
+ expect(existsSync(join(sandbox.path, ".zshrc"))).toBe(true);
+ const content = readFileSync(join(sandbox.path, ".zshrc"), "utf-8");
+ expect(content).toContain("ZSH_CONFIG");
+ });
+
+ test("writeZshrc overwrites suitup-managed .zshrc without asking", async () => {
+ writeFileSync(join(sandbox.path, ".zshrc"), "# Generated by suitup\nold content", "utf-8");
+
+ await writeZshrc("zinit", { home: sandbox.path });
+
+ const content = readFileSync(join(sandbox.path, ".zshrc"), "utf-8");
+ expect(content).not.toContain("old content");
+ expect(content).toContain("ZSH_CONFIG");
+ expect(mockConfirm).not.toHaveBeenCalled();
+ });
+
+ test("writeZshrc asks before overwriting non-suitup .zshrc", async () => {
+ writeFileSync(join(sandbox.path, ".zshrc"), "# my custom zshrc\nexport FOO=bar", "utf-8");
+ mockConfirm.mockResolvedValue(false);
+
+ await writeZshrc("zinit", { home: sandbox.path });
+
+ expect(mockConfirm).toHaveBeenCalled();
+ // Since user declined, original content preserved
+ const content = readFileSync(join(sandbox.path, ".zshrc"), "utf-8");
+ expect(content).toContain("FOO=bar");
+ });
+
+ test("writeZshrc creates backup when user confirms overwrite", async () => {
+ writeFileSync(join(sandbox.path, ".zshrc"), "# my custom zshrc\nexport FOO=bar", "utf-8");
+ mockConfirm.mockResolvedValue(true);
+
+ await writeZshrc("zinit", { home: sandbox.path });
+
+ // New content should be the template
+ const content = readFileSync(join(sandbox.path, ".zshrc"), "utf-8");
+ expect(content).toContain("ZSH_CONFIG");
+
+ // A backup file should exist
+ const { readdirSync } = await import("node:fs");
+ const files = readdirSync(sandbox.path);
+ const backupFile = files.find((f) => f.startsWith(".zshrc.backup."));
+ expect(backupFile).toBeDefined();
+ });
+
+ test("writeZshrc uses omz template when pluginManager is omz", async () => {
+ await writeZshrc("omz", { home: sandbox.path });
+
+ const content = readFileSync(join(sandbox.path, ".zshrc"), "utf-8");
+ expect(content).toContain("oh-my-zsh");
+ });
+});
+
+describe("aliases step", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sandbox = createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ test("writes aliases file in empty sandbox", async () => {
+ await setupAliases({ home: sandbox.path });
+
+ expect(existsSync(join(sandbox.path, ".config", "suitup", "aliases"))).toBe(true);
+ });
+
+ test("skips when aliases file already exists", async () => {
+ mkdirSync(join(sandbox.path, ".config", "suitup"), { recursive: true });
+ writeFileSync(join(sandbox.path, ".config", "suitup", "aliases"), "# my aliases", "utf-8");
+
+ await setupAliases({ home: sandbox.path });
+
+ const content = readFileSync(join(sandbox.path, ".config", "suitup", "aliases"), "utf-8");
+ expect(content).toBe("# my aliases");
+ });
+});
+
+describe("vim step", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sandbox = createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.cleanup();
+ });
+
+ test("writes vim config and .vimrc in empty sandbox", async () => {
+ await setupVim({ home: sandbox.path });
+
+ expect(existsSync(join(sandbox.path, ".config", "suitup", "config.vim"))).toBe(true);
+ expect(existsSync(join(sandbox.path, ".vimrc"))).toBe(true);
+
+ const vimrc = readFileSync(join(sandbox.path, ".vimrc"), "utf-8");
+ expect(vimrc).toContain("source");
+ expect(vimrc).toContain("config.vim");
+ });
+
+ test("skips vim config when already exists", async () => {
+ mkdirSync(join(sandbox.path, ".config", "suitup"), { recursive: true });
+ writeFileSync(join(sandbox.path, ".config", "suitup", "config.vim"), "\" my vim config", "utf-8");
+
+ await setupVim({ home: sandbox.path });
+
+ const content = readFileSync(join(sandbox.path, ".config", "suitup", "config.vim"), "utf-8");
+ expect(content).toBe("\" my vim config");
+ });
+
+ test("does not duplicate .vimrc source line on second run", async () => {
+ await setupVim({ home: sandbox.path });
+ await setupVim({ home: sandbox.path });
+
+ const vimrc = readFileSync(join(sandbox.path, ".vimrc"), "utf-8");
+ const sourceLines = vimrc.split("\n").filter((l) => l.includes("source"));
+ expect(sourceLines.length).toBe(1);
+ });
+});
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..462844d
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.js"],
+ testTimeout: 30_000,
+ },
+});