From cc4d2c3c3e3a0e00cb61f28fd4e32aabb84817bf Mon Sep 17 00:00:00 2001 From: "chenzihao.house" Date: Tue, 10 Mar 2026 17:23:35 +0800 Subject: [PATCH 1/8] feat: migrate suitup to modular Node CLI with cross-platform bootstrap and docs restructure --- .gitignore | 6 +- AGENTS.md | 77 ++ README.md | 195 +++- README.zh-CN.md | 164 +++ bootstrap.sh | 67 -- clean.sh | 23 - configs/aliases | 28 + configs/config.vim | 16 + configs/core/env.zsh | 6 + configs/core/options.zsh | 27 + configs/core/paths.zsh | 5 + configs/core/perf.zsh | 71 ++ configs/local/machine.zsh | 9 + configs/shared/prompt.zsh | 5 + configs/shared/tools.zsh | 68 ++ configs/zinit-plugins | 8 + configs/zshrc-omz.template | 61 + configs/zshrc.template | 60 + package-lock.json | 1616 +++++++++++++++++++++++++++ package.json | 25 + scripts/dev/vim-config.sh | 49 - scripts/dev/zsh-alias.sh | 53 - scripts/init/apps.sh | 41 - scripts/init/clean-dock.sh | 9 - scripts/init/command-line-tools.sh | 99 -- scripts/init/front-end.sh | 99 -- scripts/init/init-configs.sh | 45 - scripts/init/install-zsh-plugins.sh | 49 - scripts/init/ssh.sh | 41 - scripts/utils/log.sh | 17 - snippets/zvm-fzf.snippet.sh | 6 - src/append.js | 188 ++++ src/clean.js | 40 + src/cli.js | 32 + src/constants.js | 11 + src/setup.js | 162 +++ src/steps/aliases.js | 22 + src/steps/apps.js | 41 + src/steps/bootstrap.js | 132 +++ src/steps/cli-tools.js | 43 + src/steps/dock.js | 30 + src/steps/frontend.js | 63 ++ src/steps/plugin-manager.js | 84 ++ src/steps/ssh.js | 52 + src/steps/vim.js | 34 + src/steps/zsh-config.js | 89 ++ src/utils/fs.js | 98 ++ src/utils/shell.js | 62 + src/verify.js | 142 +++ tests/append.test.js | 88 ++ tests/apps.test.js | 50 + tests/bootstrap.test.js | 133 +++ tests/cli-tools.test.js | 52 + tests/configs.test.js | 159 +++ tests/dock.test.js | 57 + tests/frontend.test.js | 74 ++ tests/helpers.js | 17 + tests/plugin-manager.test.js | 92 ++ tests/setup.test.js | 147 +++ tests/ssh.test.js | 72 ++ tests/verify.test.js | 108 ++ tests/zsh-config-steps.test.js | 199 ++++ vitest.config.js | 8 + 63 files changed, 4979 insertions(+), 647 deletions(-) create mode 100644 AGENTS.md create mode 100644 README.zh-CN.md delete mode 100755 bootstrap.sh delete mode 100644 clean.sh create mode 100644 configs/aliases create mode 100644 configs/config.vim create mode 100644 configs/core/env.zsh create mode 100644 configs/core/options.zsh create mode 100644 configs/core/paths.zsh create mode 100644 configs/core/perf.zsh create mode 100644 configs/local/machine.zsh create mode 100644 configs/shared/prompt.zsh create mode 100644 configs/shared/tools.zsh create mode 100644 configs/zinit-plugins create mode 100644 configs/zshrc-omz.template create mode 100644 configs/zshrc.template create mode 100644 package-lock.json create mode 100644 package.json delete mode 100755 scripts/dev/vim-config.sh delete mode 100755 scripts/dev/zsh-alias.sh delete mode 100755 scripts/init/apps.sh delete mode 100644 scripts/init/clean-dock.sh delete mode 100755 scripts/init/command-line-tools.sh delete mode 100755 scripts/init/front-end.sh delete mode 100755 scripts/init/init-configs.sh delete mode 100755 scripts/init/install-zsh-plugins.sh delete mode 100644 scripts/init/ssh.sh delete mode 100755 scripts/utils/log.sh delete mode 100644 snippets/zvm-fzf.snippet.sh create mode 100644 src/append.js create mode 100644 src/clean.js create mode 100644 src/cli.js create mode 100644 src/constants.js create mode 100644 src/setup.js create mode 100644 src/steps/aliases.js create mode 100644 src/steps/apps.js create mode 100644 src/steps/bootstrap.js create mode 100644 src/steps/cli-tools.js create mode 100644 src/steps/dock.js create mode 100644 src/steps/frontend.js create mode 100644 src/steps/plugin-manager.js create mode 100644 src/steps/ssh.js create mode 100644 src/steps/vim.js create mode 100644 src/steps/zsh-config.js create mode 100644 src/utils/fs.js create mode 100644 src/utils/shell.js create mode 100644 src/verify.js create mode 100644 tests/append.test.js create mode 100644 tests/apps.test.js create mode 100644 tests/bootstrap.test.js create mode 100644 tests/cli-tools.test.js create mode 100644 tests/configs.test.js create mode 100644 tests/dock.test.js create mode 100644 tests/frontend.test.js create mode 100644 tests/helpers.js create mode 100644 tests/plugin-manager.test.js create mode 100644 tests/setup.test.js create mode 100644 tests/ssh.test.js create mode 100644 tests/verify.test.js create mode 100644 tests/zsh-config-steps.test.js create mode 100644 vitest.config.js 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..52a8640 --- /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 +- `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..6d9ec63 --- /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..bf2c384 --- /dev/null +++ b/configs/core/perf.zsh @@ -0,0 +1,71 @@ +# ============================================================================ +# Startup timing and performance report +# ============================================================================ + +zmodload zsh/datetime 2>/dev/null + +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/prompt.zsh b/configs/shared/prompt.zsh new file mode 100644 index 0000000..54a40a2 --- /dev/null +++ b/configs/shared/prompt.zsh @@ -0,0 +1,5 @@ +# ============================================================================ +# Prompt / theme loading +# ============================================================================ + +[[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh diff --git a/configs/shared/tools.zsh b/configs/shared/tools.zsh new file mode 100644 index 0000000..fdd59b4 --- /dev/null +++ b/configs/shared/tools.zsh @@ -0,0 +1,68 @@ +# ============================================================================ +# External tool configuration and initialization +# ============================================================================ + +# 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' +" + +_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..9e8bac3 --- /dev/null +++ b/configs/zinit-plugins @@ -0,0 +1,8 @@ +# >>> suitup zinit-plugins >>> + +zinit load 'zsh-users/zsh-autosuggestions' +zinit load 'zsh-users/zsh-syntax-highlighting' +zinit ice depth"1" +zinit light romkatv/powerlevel10k + +# <<< 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..4961d06 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "suitup", + "version": "2.0.0", + "description": "Opinionated macOS development environment setup tool with beautiful TUI", + "type": "module", + "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": "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..f916cfa --- /dev/null +++ b/src/append.js @@ -0,0 +1,188 @@ +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 toolsContent = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); + // Extract only FZF-related config (everything before "# Tool initialization") + const fzfPart = toolsContent.split("# Tool initialization")[0].trim(); + const block = `\n# >>> suitup/fzf-config >>>\n${fzfPart}\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..0f53236 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +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..7ac8b2c --- /dev/null +++ b/src/steps/ssh.js @@ -0,0 +1,52 @@ +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; + + p.log.step("Generating SSH key..."); + await runStream( + `ssh-keygen -t rsa -b 4096 -C "${email}" -f "${keyFile}" -N ""` + ); + + // 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..9ef2529 --- /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", "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..f4a4117 --- /dev/null +++ b/src/utils/fs.js @@ -0,0 +1,98 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + appendFileSync, + copyFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +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(import.meta.dirname, "..", ".."); +} diff --git a/src/utils/shell.js b/src/utils/shell.js new file mode 100644 index 0000000..244e503 --- /dev/null +++ b/src/utils/shell.js @@ -0,0 +1,62 @@ +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. + */ +export function runStream(cmd) { + return new Promise((resolve, reject) => { + const child = spawn("bash", ["-c", cmd], { stdio: "inherit" }); + 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..61d9e62 --- /dev/null +++ b/tests/append.test.js @@ -0,0 +1,88 @@ +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"; + +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"); + }); +}); 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..0506e76 --- /dev/null +++ b/tests/configs.test.js @@ -0,0 +1,159 @@ +import { describe, test, expect } 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"); + expect(content).toContain("powerlevel10k"); + }); + + 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"]) { + 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/tools.zsh uses fd instead of rg for FZF_DEFAULT_COMMAND", () => { + const content = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); + expect(content).toContain("fd --type"); + expect(content).toContain("fnm"); + expect(content).toContain("atuin"); + expect(content).toContain("zoxide"); + expect(content).toContain("command -v"); + }); + + 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/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/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+/); + } + }); +}); 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/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..25b6f11 --- /dev/null +++ b/tests/setup.test.js @@ -0,0 +1,147 @@ +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"); + }); + + 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..bf5c0cc --- /dev/null +++ b/tests/ssh.test.js @@ -0,0 +1,72 @@ +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 } = vi.hoisted(() => ({ + mockText: 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, + 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"); + }); + + 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(); + expect(runStream).toHaveBeenCalledWith( + expect.stringContaining("ssh-keygen") + ); + expect(runStream).toHaveBeenCalledWith( + expect.stringContaining("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); + }); +}); 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..5a719b7 --- /dev/null +++ b/tests/zsh-config-steps.test.js @@ -0,0 +1,199 @@ +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", "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"); + + expect(perf).toContain("EPOCHREALTIME"); + expect(perf).toContain("_record_stage_duration"); + expect(tools).toContain("_source_cached_tool_init"); + expect(tools).toContain("$_zsh_tools_cache_dir"); + }); + + 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, + }, +}); From 14a1d6d49cc1d14a2cf474c495f8099b2f20ca15 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:43:12 +0800 Subject: [PATCH 2/8] docs: clarify secrets.zsh path in AGENTS.md (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `local/secrets.zsh` reference in `AGENTS.md` was ambiguous — it omitted the full installed path, inconsistent with how the zshrc templates source it and the layout described in `README.md`. ## Changes - **`AGENTS.md`**: Updated `local/secrets.zsh` → `~/.config/zsh/local/secrets.zsh` in the Local files section --- ✨ Let Copilot coding agent [set things up for you](https://github.com/ChangeHow/suitup/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 52a8640..e36486f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ The actual config lives under `~/.config/zsh/`. ### Local files - `configs/local/machine.zsh`: machine-specific overrides placeholder -- `local/secrets.zsh` is user-managed and intentionally not shipped by suitup +- `~/.config/zsh/local/secrets.zsh` is user-managed and intentionally not shipped by suitup ## Templates From 37dbcd50720b0089e6cf4ff530c9258e478c3f95 Mon Sep 17 00:00:00 2001 From: ChangeHow <23733347+ChangeHow@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:46:21 +0800 Subject: [PATCH 3/8] chore: update comment for configs/config.vim --- configs/config.vim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/config.vim b/configs/config.vim index 6d9ec63..353171f 100644 --- a/configs/config.vim +++ b/configs/config.vim @@ -9,7 +9,7 @@ hi CursorLine cterm=NONE ctermbg=235 guibg=Grey10 " shortcuts imap jk -"" navigation +" navigation nmap 3j nmap 3k nmap 3e From f610178adac5caf35368cdf227fe439dc7d73f35 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:17:07 +0800 Subject: [PATCH 4/8] ci: add GitHub Actions workflow to run tests on every PR and push (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [x] Investigate CI failure logs - [x] Fix: add `apt-get install zsh` step — tests use `zsh -n` for syntax checking but zsh is not pre-installed on ubuntu-latest --- ✨ Let Copilot coding agent [set things up for you](https://github.com/ChangeHow/suitup/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- .github/workflows/test.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/test.yml 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 From cb93126824852116f9512c082f3205db59b36093 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:45:42 +0800 Subject: [PATCH 5/8] Add optional passphrase prompt to SSH key generation (#13) --- src/steps/ssh.js | 14 +++++++++++- src/utils/shell.js | 8 +++++-- tests/ssh.test.js | 55 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/steps/ssh.js b/src/steps/ssh.js index 7ac8b2c..ead1905 100644 --- a/src/steps/ssh.js +++ b/src/steps/ssh.js @@ -29,9 +29,21 @@ export async function setupSsh({ home } = {}) { 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 -t rsa -b 4096 -C "${email}" -f "${keyFile}" -N "$SSH_KEYGEN_PASSPHRASE"`, + { env: { ...process.env, SSH_KEYGEN_PASSPHRASE: passphrase } } ); // Copy public key to clipboard diff --git a/src/utils/shell.js b/src/utils/shell.js index 244e503..caac8f5 100644 --- a/src/utils/shell.js +++ b/src/utils/shell.js @@ -52,10 +52,14 @@ export function brewInstall(name, { cask = 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) { +export function runStream(cmd, opts = {}) { return new Promise((resolve, reject) => { - const child = spawn("bash", ["-c", cmd], { stdio: "inherit" }); + 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/tests/ssh.test.js b/tests/ssh.test.js index bf5c0cc..b7a54d8 100644 --- a/tests/ssh.test.js +++ b/tests/ssh.test.js @@ -3,8 +3,9 @@ import { mkdirSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createSandbox } from "./helpers.js"; -const { mockText } = vi.hoisted(() => ({ +const { mockText, mockPassword } = vi.hoisted(() => ({ mockText: vi.fn(), + mockPassword: vi.fn(), })); vi.mock("@clack/prompts", () => ({ @@ -12,6 +13,7 @@ vi.mock("@clack/prompts", () => ({ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), confirm: vi.fn(), text: mockText, + password: mockPassword, isCancel: vi.fn(() => false), })); @@ -33,6 +35,7 @@ describe("ssh step", () => { vi.clearAllMocks(); sandbox = createSandbox(); mockText.mockResolvedValue("test@example.com"); + mockPassword.mockResolvedValue(""); }); afterEach(() => { @@ -54,12 +57,9 @@ describe("ssh step", () => { await setupSsh({ home: sandbox.path }); expect(mockText).toHaveBeenCalled(); - expect(runStream).toHaveBeenCalledWith( - expect.stringContaining("ssh-keygen") - ); - expect(runStream).toHaveBeenCalledWith( - expect.stringContaining("test@example.com") - ); + 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 () => { @@ -69,4 +69,45 @@ describe("ssh step", () => { 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") }) + ); + }); }); From 44ecfd9f4d3ab19567a75b69a367a27189348df1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:50:49 +0800 Subject: [PATCH 6/8] perf.zsh: guard against zsh/datetime unavailability (#12) --- configs/core/perf.zsh | 7 ++ package.json | 2 +- tests/configs.test.js | 56 +++++++++++++++- tests/perf.zsh | 153 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 tests/perf.zsh diff --git a/configs/core/perf.zsh b/configs/core/perf.zsh index bf2c384..6427605 100644 --- a/configs/core/perf.zsh +++ b/configs/core/perf.zsh @@ -4,6 +4,13 @@ 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 diff --git a/package.json b/package.json index 4961d06..c3f7dc1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "append": "node src/cli.js append", "verify": "node src/cli.js verify", "clean": "node src/cli.js clean", - "test": "vitest run", + "test": "zsh tests/perf.zsh && vitest run", "test:watch": "vitest" }, "keywords": ["dotfiles", "macos", "setup", "zsh", "cli"], diff --git a/tests/configs.test.js b/tests/configs.test.js index 0506e76..c40111c 100644 --- a/tests/configs.test.js +++ b/tests/configs.test.js @@ -1,4 +1,4 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, beforeEach } from "vitest"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { execSync } from "node:child_process"; @@ -157,3 +157,57 @@ describe("Static config templates", () => { } }); }); + +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/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 )) From e200c08833420ac2fb057a0ee6f70bd477b063e3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:24:19 +0800 Subject: [PATCH 7/8] Extract FZF config into configs/shared/fzf.zsh and load p10k last (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `fzf-config` append block extracted FZF env vars from `tools.zsh` by splitting on a comment string (`# Tool initialization`), silently breaking if that comment ever changed. Additionally, the Powerlevel10k theme was being loaded in the suitup stage instead of as the final step. ## Changes - **`configs/shared/fzf.zsh`** — new standalone file holding only `FZF_DEFAULT_COMMAND`, `FZF_CTRL_T_COMMAND`, and `FZF_CTRL_T_OPTS` - **`configs/shared/tools.zsh`** — FZF vars removed; replaced with an inline conditional source of `fzf.zsh` (avoids `source_if_exists` dependency): ```zsh [[ -f "${ZDOTDIR:-$HOME/.config/zsh}/shared/fzf.zsh" ]] && source "${ZDOTDIR:-$HOME/.config/zsh}/shared/fzf.zsh" ``` - **`src/append.js`** — `fzf-config` block reads `fzf.zsh` directly via `readFileSync` instead of parsing `tools.zsh` - **`src/steps/zsh-config.js`** — `fzf.zsh` added to `sharedFiles` so it's copied to `~/.config/zsh/shared/` during setup - **`configs/zinit-plugins`** — removed `zinit light romkatv/powerlevel10k` so p10k is no longer loaded in the suitup stage (step 7) - **`configs/shared/prompt.zsh`** — now loads the p10k theme as the very last step (prompt stage, step 9), guarded with `(( ${+functions[zinit]} ))` so it is a no-op for OMZ setups where p10k is handled by OMZ itself - **Tests** — 6 new cases in `append.test.js` assert `fzf.zsh` exists standalone, contains only FZF vars (no cache helpers), and that the appended block is idempotent; `configs.test.js` updated to assert p10k is in `prompt.zsh` (not `zinit-plugins`) and `setup.test.js` asserts `prompt.zsh` is sourced after `zinit-plugins` in the template loading order --- ✨ Let Copilot coding agent [set things up for you](https://github.com/ChangeHow/suitup/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- configs/shared/fzf.zsh | 33 ++++++++++++++++ configs/shared/prompt.zsh | 6 ++- configs/shared/tools.zsh | 35 +---------------- configs/zinit-plugins | 2 - src/append.js | 6 +-- src/steps/zsh-config.js | 2 +- tests/append.test.js | 70 ++++++++++++++++++++++++++++++++++ tests/configs.test.js | 29 ++++++++++++-- tests/setup.test.js | 7 ++++ tests/zsh-config-steps.test.js | 4 ++ 10 files changed, 149 insertions(+), 45 deletions(-) create mode 100644 configs/shared/fzf.zsh 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 index 54a40a2..685ee2c 100644 --- a/configs/shared/prompt.zsh +++ b/configs/shared/prompt.zsh @@ -1,5 +1,9 @@ # ============================================================================ -# Prompt / theme loading +# 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 index fdd59b4..900db55 100644 --- a/configs/shared/tools.zsh +++ b/configs/shared/tools.zsh @@ -2,39 +2,8 @@ # External tool configuration and initialization # ============================================================================ -# 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' -" +# 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" diff --git a/configs/zinit-plugins b/configs/zinit-plugins index 9e8bac3..5953007 100644 --- a/configs/zinit-plugins +++ b/configs/zinit-plugins @@ -2,7 +2,5 @@ zinit load 'zsh-users/zsh-autosuggestions' zinit load 'zsh-users/zsh-syntax-highlighting' -zinit ice depth"1" -zinit light romkatv/powerlevel10k # <<< suitup zinit-plugins <<< diff --git a/src/append.js b/src/append.js index f916cfa..132630b 100644 --- a/src/append.js +++ b/src/append.js @@ -108,10 +108,8 @@ const BLOCKS = [ group: "Advanced", marker: "suitup/fzf-config", apply() { - const toolsContent = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); - // Extract only FZF-related config (everything before "# Tool initialization") - const fzfPart = toolsContent.split("# Tool initialization")[0].trim(); - const block = `\n# >>> suitup/fzf-config >>>\n${fzfPart}\n# <<< suitup/fzf-config <<<\n`; + 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"); }, }, diff --git a/src/steps/zsh-config.js b/src/steps/zsh-config.js index 9ef2529..3740f71 100644 --- a/src/steps/zsh-config.js +++ b/src/steps/zsh-config.js @@ -29,7 +29,7 @@ export async function setupZshConfig({ home } = {}) { } // Copy shared configs (skip if already exist) - const sharedFiles = ["tools.zsh", "prompt.zsh"]; + 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)`); diff --git a/tests/append.test.js b/tests/append.test.js index 61d9e62..748eda9 100644 --- a/tests/append.test.js +++ b/tests/append.test.js @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "no 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; @@ -86,3 +87,72 @@ describe("Append mode utilities", () => { 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/configs.test.js b/tests/configs.test.js index c40111c..cd74016 100644 --- a/tests/configs.test.js +++ b/tests/configs.test.js @@ -56,7 +56,19 @@ describe("Static config templates", () => { 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", () => { @@ -70,7 +82,7 @@ describe("Static config templates", () => { }); test("shared config files exist", () => { - for (const file of ["tools.zsh", "prompt.zsh"]) { + for (const file of ["tools.zsh", "prompt.zsh", "fzf.zsh"]) { expect(existsSync(join(CONFIGS_DIR, "shared", file))).toBe(true); } }); @@ -94,13 +106,20 @@ describe("Static config templates", () => { } }); - test("shared/tools.zsh uses fd instead of rg for FZF_DEFAULT_COMMAND", () => { + 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("fd --type"); expect(content).toContain("fnm"); expect(content).toContain("atuin"); expect(content).toContain("zoxide"); - expect(content).toContain("command -v"); + expect(content).toContain("fzf.zsh"); }); test("all .zsh config files pass syntax check", () => { @@ -110,6 +129,7 @@ describe("Static config templates", () => { "core/paths.zsh", "core/options.zsh", "shared/tools.zsh", + "shared/fzf.zsh", "shared/prompt.zsh", "local/machine.zsh", ]; @@ -144,6 +164,7 @@ describe("Static config templates", () => { "core/paths.zsh", "core/options.zsh", "shared/tools.zsh", + "shared/fzf.zsh", "shared/prompt.zsh", "zshrc.template", "zshrc-omz.template", diff --git a/tests/setup.test.js b/tests/setup.test.js index 25b6f11..e61f94b 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -101,6 +101,13 @@ describe("Setup simulation in sandbox", () => { 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", () => { diff --git a/tests/zsh-config-steps.test.js b/tests/zsh-config-steps.test.js index 5a719b7..7095e62 100644 --- a/tests/zsh-config-steps.test.js +++ b/tests/zsh-config-steps.test.js @@ -39,6 +39,7 @@ describe("zsh-config step", () => { 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); }); @@ -48,11 +49,14 @@ describe("zsh-config step", () => { 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 () => { From 98a69356661b3313f69438c6a9097fb432708dd9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:00:26 +0800 Subject: [PATCH 8/8] Add Node.js version gate and fix ESM path resolution (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cli.js` uses top-level `await` and native ESM, requiring Node ≥ 18. There was no runtime guard against older versions, and `src/utils/fs.js` had a silent bug (`import.meta.dirname` is non-standard and returns `undefined`). ## Changes - **`src/utils/node-version.js`** — new utility; checks `process.version` against the Node 18 minimum and exits with a clear upgrade message if not met - **`src/cli.js`** — calls `checkNodeVersion()` before any setup logic - **`package.json`** — adds `"engines": { "node": ">=18.0.0" }` for tooling-level enforcement - **`src/utils/fs.js`** — fixes `projectRoot()` to use `dirname(fileURLToPath(import.meta.url))` instead of `import.meta.dirname` ```js // src/utils/node-version.js export function checkNodeVersion() { const major = parseInt(process.version.replace(/^v/, "").split(".")[0], 10); if (major < 18) { console.error( `suitup requires Node.js 18 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); } } ``` --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- package.json | 3 +++ src/cli.js | 3 +++ src/utils/fs.js | 3 ++- src/utils/node-version.js | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/utils/node-version.js diff --git a/package.json b/package.json index c3f7dc1..936b8d7 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "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", diff --git a/src/cli.js b/src/cli.js index 0f53236..df66a04 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,5 +1,8 @@ #!/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"; diff --git a/src/utils/fs.js b/src/utils/fs.js index f4a4117..a437a67 100644 --- a/src/utils/fs.js +++ b/src/utils/fs.js @@ -7,6 +7,7 @@ import { copyFileSync, } from "node:fs"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; /** @@ -94,5 +95,5 @@ export function expandHome(p) { * Get the suitup project root (where configs/ lives). */ export function projectRoot() { - return join(import.meta.dirname, "..", ".."); + 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); + } +}