diff --git a/README.md b/README.md index 7e15fb3..852fd52 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Suitup can bootstrap Zsh and Homebrew for you, but the most reliable path is to ### Install and run -Suitup now assumes zsh is already installed and that you are running the command from a zsh session. +When you run suitup locally from the repo, use a zsh session. The curl installer can bootstrap missing prerequisites for you on a fresh machine. ### Quick install via curl @@ -48,7 +48,16 @@ Suitup now assumes zsh is already installed and that you are running the command curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash ``` -The installer first asks whether you want `init` (full setup) or `append` (incremental updates to an existing `.zshrc`), then downloads a temporary copy of the repo, runs `npm ci`, and launches the matching `node src/cli.js` command inside `zsh`. +The installer now defaults to `init`, bootstraps missing `zsh` and Node.js/npm when possible, downloads a temporary copy of the repo, runs `npm ci`, and launches suitup inside `zsh`. + +`init` is a non-interactive quick-start path that uses recommended defaults: + +- bootstrap package manager + zsh when needed +- install the layered zsh config +- install zinit + Powerlevel10k preset +- install recommended CLI tools and frontend tooling +- install recommended GUI apps on macOS +- write shared aliases You can also pass a specific command to the installer: @@ -75,6 +84,7 @@ node src/cli.js | Command | Description | |---------|-------------| +| `node src/cli.js init` | Non-interactive quick init with recommended defaults | | `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` | @@ -222,13 +232,15 @@ After setup, your shell config looks like: options.zsh # Zsh shell options shared/ tools.zsh # Tool init (fzf, atuin, zoxide, fnm) + plugins.zsh # zinit plugin declarations + highlighting.zsh # zsh-syntax-highlighting styles + aliases.zsh # Shared aliases + completion.zsh # Native completion setup 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 ``` @@ -247,8 +259,8 @@ Implementation details and architecture notes live in `AGENTS.md`. - macOS (full support, tested on Sonoma+) - Linux (bootstrap package-manager selection supported; most install steps still target Homebrew ecosystem) -- Node.js >= 18 -- Zsh installed locally +- Node.js >= 18 for local repo usage; the curl installer bootstraps it when possible +- Zsh for local repo usage; the curl installer bootstraps it when possible - Run suitup from a zsh session (`echo $SHELL` should end with `zsh`) ## License diff --git a/configs/local/machine.zsh b/configs/local/machine.zsh index 0d8d241..b663476 100644 --- a/configs/local/machine.zsh +++ b/configs/local/machine.zsh @@ -1,9 +1,9 @@ # ============================================================================ # Machine-specific overrides # ============================================================================ -# Add local path additions, work profile toggles, or any other settings +# Add local path additions, machine-only toggles, or any other settings # that apply only to this machine. # # Example: -# export ZSH_WORK_PROFILE=1 # load work/* config on this machine +# export EDITOR="nvim" # path=("/usr/local/custom/bin" $path) diff --git a/configs/shared/aliases.zsh b/configs/shared/aliases.zsh new file mode 100644 index 0000000..eab7b1f --- /dev/null +++ b/configs/shared/aliases.zsh @@ -0,0 +1,31 @@ +# ============================================================================ +# Shared aliases +# Generated by suitup +# ============================================================================ + +# utilities +alias reload-zsh="source ~/.zshrc" +alias edit-zsh="${EDITOR:-vi} ~/.zshrc" +alias edit-plugins="${EDITOR:-vi} ~/.config/zsh/shared/plugins.zsh" +alias edit-aliases="${EDITOR:-vi} ~/.config/zsh/shared/aliases.zsh" +alias 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='local b; b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); [[ $b != HEAD ]] && gph -u origin "$b"' +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' diff --git a/configs/shared/completion.zsh b/configs/shared/completion.zsh new file mode 100644 index 0000000..7a17d0d --- /dev/null +++ b/configs/shared/completion.zsh @@ -0,0 +1,20 @@ +# ============================================================================ +# Native zsh completion +# Generated by suitup +# ============================================================================ + +autoload -Uz compinit + +_zsh_compdump_file="${XDG_CACHE_HOME:-$HOME/.cache}/zsh/.zcompdump" +typeset -g _zsh_completion_cache_mode='refresh' + +if [[ -s "$_zsh_compdump_file" && -n "$(command find "$_zsh_compdump_file" -mtime -7 -print 2>/dev/null)" ]]; then + _zsh_completion_cache_mode='cache-hit' + compinit -C -d "$_zsh_compdump_file" +else + compinit -d "$_zsh_compdump_file" +fi + +bindkey -M emacs '^I' expand-or-complete +bindkey -M viins '^I' expand-or-complete +bindkey -M vicmd '^I' expand-or-complete diff --git a/configs/shared/highlighting.zsh b/configs/shared/highlighting.zsh new file mode 100644 index 0000000..f03de99 --- /dev/null +++ b/configs/shared/highlighting.zsh @@ -0,0 +1,20 @@ +# ============================================================================ +# Interactive shell highlighting +# Generated by suitup +# ============================================================================ + +typeset -gA ZSH_HIGHLIGHT_STYLES + +ZSH_HIGHLIGHT_STYLES[arg0]='fg=green,bold' +ZSH_HIGHLIGHT_STYLES[single-hyphen-option]='fg=cyan' +ZSH_HIGHLIGHT_STYLES[double-hyphen-option]='fg=cyan' +ZSH_HIGHLIGHT_STYLES[path]='fg=blue,underline' +ZSH_HIGHLIGHT_STYLES[globbing]='fg=magenta' +ZSH_HIGHLIGHT_STYLES[history-expansion]='fg=blue,bold' +ZSH_HIGHLIGHT_STYLES[single-quoted-argument]='fg=yellow' +ZSH_HIGHLIGHT_STYLES[double-quoted-argument]='fg=yellow' +ZSH_HIGHLIGHT_STYLES[dollar-double-quoted-argument]='fg=magenta' +ZSH_HIGHLIGHT_STYLES[command-substitution-delimiter]='fg=magenta,bold' +ZSH_HIGHLIGHT_STYLES[process-substitution-delimiter]='fg=magenta,bold' +ZSH_HIGHLIGHT_STYLES[redirection]='fg=red,bold' +ZSH_HIGHLIGHT_STYLES[reserved-word]='fg=yellow,bold' diff --git a/configs/shared/plugins.zsh b/configs/shared/plugins.zsh new file mode 100644 index 0000000..3f7c012 --- /dev/null +++ b/configs/shared/plugins.zsh @@ -0,0 +1,13 @@ +# ============================================================================ +# zinit plugin declarations +# Generated by suitup +# ============================================================================ + +zinit ice wait"0" lucid atload'_zsh_autosuggest_bind_widgets' +zinit light 'zsh-users/zsh-autosuggestions' + +zinit ice wait"0" lucid +zinit light 'zsh-users/zsh-syntax-highlighting' + +zinit ice depth"1" +zinit light romkatv/powerlevel10k diff --git a/configs/shared/prompt.zsh b/configs/shared/prompt.zsh index e00d83b..dfed6f8 100644 --- a/configs/shared/prompt.zsh +++ b/configs/shared/prompt.zsh @@ -3,12 +3,6 @@ # Generated by suitup (Powerlevel10k preset) # ============================================================================ -if (( ${+functions[zinit]} )); then - # Load p10k last so it wraps everything and exposes the `p10k` command. - zinit ice depth"1" - zinit light romkatv/powerlevel10k -fi - if [[ -f ~/.p10k.zsh ]]; then source ~/.p10k.zsh else diff --git a/configs/zshrc.template b/configs/zshrc.template index cf03cde..34d9e1d 100644 --- a/configs/zshrc.template +++ b/configs/zshrc.template @@ -33,14 +33,18 @@ source "$ZSH_CONFIG/shared/tools.zsh" # --------------------------------------------------------------------------- # Plugin manager # --------------------------------------------------------------------------- -_stage "zinit" +_stage "plugins" ZINIT_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/zinit/zinit.git" source_if_exists "${ZINIT_HOME}/zinit.zsh" -# Suitup -_stage "suitup" -source_if_exists "$HOME/.config/suitup/zinit-plugins" -source_if_exists "$HOME/.config/suitup/aliases" +# Shared shell config +source_if_exists "$ZSH_CONFIG/shared/plugins.zsh" +source_if_exists "$ZSH_CONFIG/shared/highlighting.zsh" +source_if_exists "$ZSH_CONFIG/shared/aliases.zsh" + +# Completion system +_stage "completion" +source_if_exists "$ZSH_CONFIG/shared/completion.zsh" # --------------------------------------------------------------------------- # Local overrides diff --git a/install.sh b/install.sh old mode 100755 new mode 100644 index 31e336a..e23151a --- a/install.sh +++ b/install.sh @@ -7,6 +7,7 @@ SUITUP_REF="${SUITUP_REF:-main}" ARCHIVE_URL="https://github.com/${REPO_SLUG}/archive/refs/heads/${SUITUP_REF}.tar.gz" WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/suitup-install.XXXXXX")" ARCHIVE_PATH="${WORK_DIR}/suitup.tar.gz" +OS_NAME="$(uname -s)" cleanup() { rm -rf "${WORK_DIR}" @@ -14,8 +15,16 @@ cleanup() { trap cleanup EXIT +log() { + printf '==> %s\n' "$1" >&2 +} + +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + require_cmd() { - if command -v "$1" >/dev/null 2>&1; then + if have_cmd "$1"; then return 0 fi @@ -23,55 +32,192 @@ require_cmd() { exit 1 } -require_cmd curl -require_cmd tar -require_cmd zsh -require_cmd node -require_cmd npm - -node -e ' -const major = Number(process.versions.node.split(".")[0]); -if (major < 18) { - console.error(`suitup requires Node.js 18 or later. You are running ${process.version}.`); - process.exit(1); +activate_brew() { + local brew_bin + for brew_bin in \ + /opt/homebrew/bin/brew \ + /home/linuxbrew/.linuxbrew/bin/brew \ + /usr/local/bin/brew + do + if [[ -x "${brew_bin}" ]]; then + eval "$("${brew_bin}" shellenv)" + return 0 + fi + done + + return 1 +} + +ensure_homebrew_on_mac() { + if have_cmd brew; then + activate_brew || true + return 0 + fi + + log "Installing Homebrew..." + NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + activate_brew } -' -CLI_COMMAND="${1:-}" -if [[ -n "${CLI_COMMAND}" ]]; then +detect_package_manager() { + if [[ "${OS_NAME}" == "Darwin" ]]; then + ensure_homebrew_on_mac + printf 'brew\n' + return 0 + fi + + local manager + for manager in apt-get dnf yum brew; do + if have_cmd "${manager}"; then + printf '%s\n' "${manager}" + return 0 + fi + done + + return 1 +} + +install_with_manager() { + local manager="$1" shift - if [[ "${CLI_COMMAND}" == "init" ]]; then - CLI_COMMAND="setup" + + case "${manager}" in + brew) + brew install "$@" + ;; + apt-get) + sudo apt-get update + sudo apt-get install -y "$@" + ;; + dnf) + sudo dnf install -y "$@" + ;; + yum) + sudo yum install -y "$@" + ;; + *) + echo "Unsupported package manager: ${manager}" >&2 + exit 1 + ;; + esac +} + +node_major() { + if ! have_cmd node; then + return 1 fi -else - echo "Choose install mode:" - echo " 1) init - full interactive setup" - echo " 2) append - incremental config updates for an existing ~/.zshrc" - read -r -p "Select [1-2] (default 1): " INSTALL_MODE < /dev/tty - case "${INSTALL_MODE:-1}" in - 1) - CLI_COMMAND="setup" + + node -p 'process.versions.node.split(".")[0]' +} + +ensure_zsh() { + if have_cmd zsh; then + return 0 + fi + + local manager="$1" + log "Installing zsh..." + install_with_manager "${manager}" zsh +} + +ensure_node_runtime() { + local manager="$1" + local major + + if have_cmd node && have_cmd npm; then + major="$(node_major)" + if [[ -n "${major}" && "${major}" -ge 20 ]]; then + return 0 + fi + fi + + log "Installing Node.js and npm..." + case "${manager}" in + brew) + install_with_manager "${manager}" node + ;; + apt-get) + log "Adding NodeSource LTS repository..." + if ! curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -; then + echo "Failed to set up NodeSource repository for Node.js." >&2 + exit 1 + fi + install_with_manager "${manager}" nodejs ;; - 2) - CLI_COMMAND="append" + dnf|yum) + log "Adding NodeSource LTS repository..." + if ! curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo -E bash -; then + echo "Failed to set up NodeSource repository for Node.js." >&2 + exit 1 + fi + install_with_manager "${manager}" nodejs ;; *) - echo "Invalid selection: ${INSTALL_MODE}. Please enter 1 for init or 2 for append." >&2 + echo "No supported package manager available to install Node.js." >&2 exit 1 ;; esac + + if ! have_cmd node || ! have_cmd npm; then + echo "Failed to install Node.js/npm automatically." >&2 + exit 1 + fi + + major="$(node_major)" + if [[ -z "${major}" || "${major}" -lt 20 ]]; then + echo "suitup requires Node.js 20 or later. Installed version is $(node -v)." >&2 + exit 1 + fi +} + +launch_cli() { + local repo_dir="$1" + shift + + if [[ -r /dev/tty ]]; then + zsh -lc 'cd "$1" && shift && node src/cli.js "$@"' -- "${repo_dir}" "$@" < /dev/tty + else + zsh -lc 'cd "$1" && shift && node src/cli.js "$@"' -- "${repo_dir}" "$@" + fi +} + +require_cmd curl +require_cmd tar +require_cmd uname + +PACKAGE_MANAGER="$(detect_package_manager || true)" +if [[ -z "${PACKAGE_MANAGER}" ]]; then + echo "Could not detect a supported package manager. Install zsh and Node.js 20+ manually, then rerun suitup." >&2 + exit 1 fi -echo "Downloading ${REPO_SLUG}@${SUITUP_REF}..." +ensure_zsh "${PACKAGE_MANAGER}" +ensure_node_runtime "${PACKAGE_MANAGER}" + +CLI_COMMAND="${1:-init}" +if [[ $# -gt 0 ]]; then + shift +fi + +case "${CLI_COMMAND}" in + init|setup|append|verify|clean|migrate-paths|help|--help|-h) + ;; + *) + echo "Unknown command: ${CLI_COMMAND}" >&2 + exit 1 + ;; +esac + +log "Downloading ${REPO_SLUG}@${SUITUP_REF}..." curl --fail --show-error --silent --location "${ARCHIVE_URL}" --output "${ARCHIVE_PATH}" -echo "Extracting archive..." +log "Extracting archive..." mkdir -p "${WORK_DIR}/repo" tar -xzf "${ARCHIVE_PATH}" --strip-components=1 -C "${WORK_DIR}/repo" -echo "Installing suitup dependencies..." +log "Installing suitup dependencies..." cd "${WORK_DIR}/repo" npm ci --no-fund --no-audit -echo "Launching suitup inside zsh..." -zsh -lc 'cd "$1" && shift && node src/cli.js "$@"' -- "${WORK_DIR}/repo" "${CLI_COMMAND}" "$@" < /dev/tty +log "Launching suitup inside zsh..." +launch_cli "${WORK_DIR}/repo" "${CLI_COMMAND}" "$@" diff --git a/src/append.js b/src/append.js index 2394f83..68de595 100644 --- a/src/append.js +++ b/src/append.js @@ -12,7 +12,7 @@ import { installFrontendTools } from "./steps/frontend.js"; import { commandExists } from "./utils/shell.js"; const ZSHRC = join(homedir(), ".zshrc"); -const SUITUP_DIR = join(homedir(), ".config", "suitup"); +const ZSH_SHARED_DIR = join(homedir(), ".config", "zsh", "shared"); const TOOLS_INIT_COMMANDS = ["atuin", "fzf", "zoxide", "fnm"]; function sourcePromptTemplate(preset) { @@ -51,6 +51,21 @@ export function ensurePromptSource({ home } = {}) { ); } +export function ensurePluginsSource({ home } = {}) { + const base = home || homedir(); + const zshrc = join(base, ".zshrc"); + const existing = readFileSafe(zshrc); + if (existing.includes(".config/zsh/shared/plugins.zsh") || existing.includes("suitup/zinit-plugins")) { + return false; + } + + return appendIfMissing( + zshrc, + '\n# >>> suitup/zinit-plugins >>>\nsource_if_exists "$HOME/.config/zsh/shared/plugins.zsh"\n# <<< suitup/zinit-plugins <<<\n', + "suitup/zinit-plugins" + ); +} + export function getMissingToolsInitCommands(commandExistsFn = commandExists) { return TOOLS_INIT_COMMANDS.filter((tool) => !commandExistsFn(tool)); } @@ -86,15 +101,15 @@ const BLOCKS = [ { value: "suitup-aliases", label: "Suitup aliases", - hint: "source ~/.config/suitup/aliases", + hint: "source ~/.config/zsh/shared/aliases.zsh", group: "Suitup Configs", marker: "suitup/aliases", apply() { - ensureDir(SUITUP_DIR); - copyFile(join(CONFIGS_DIR, "aliases"), join(SUITUP_DIR, "aliases")); + ensureDir(ZSH_SHARED_DIR); + copyFile(join(CONFIGS_DIR, "shared", "aliases.zsh"), join(ZSH_SHARED_DIR, "aliases.zsh")); return appendIfMissing( ZSHRC, - '\n# >>> suitup/aliases >>>\nsource_if_exists "$HOME/.config/suitup/aliases"\n# <<< suitup/aliases <<<\n', + '\n# >>> suitup/aliases >>>\nsource_if_exists "$HOME/.config/zsh/shared/aliases.zsh"\n# <<< suitup/aliases <<<\n', "suitup/aliases" ); }, @@ -102,15 +117,15 @@ const BLOCKS = [ { value: "suitup-plugins", label: "Zinit plugins", - hint: "source ~/.config/suitup/zinit-plugins", + hint: "source ~/.config/zsh/shared/plugins.zsh", group: "Suitup Configs", marker: "suitup/zinit-plugins", apply() { - ensureDir(SUITUP_DIR); - copyFile(join(CONFIGS_DIR, "zinit-plugins"), join(SUITUP_DIR, "zinit-plugins")); + ensureDir(ZSH_SHARED_DIR); + copyFile(join(CONFIGS_DIR, "shared", "plugins.zsh"), join(ZSH_SHARED_DIR, "plugins.zsh")); return appendIfMissing( ZSHRC, - '\n# >>> suitup/zinit-plugins >>>\nsource_if_exists "$HOME/.config/suitup/zinit-plugins"\n# <<< suitup/zinit-plugins <<<\n', + '\n# >>> suitup/zinit-plugins >>>\nsource_if_exists "$HOME/.config/zsh/shared/plugins.zsh"\n# <<< suitup/zinit-plugins <<<\n', "suitup/zinit-plugins" ); }, @@ -125,8 +140,11 @@ const BLOCKS = [ }, async apply() { const changed = await writePromptPreset("p10k"); + ensureDir(ZSH_SHARED_DIR); + copyIfNotExists(join(CONFIGS_DIR, "shared", "plugins.zsh"), join(ZSH_SHARED_DIR, "plugins.zsh")); + const pluginsSourced = ensurePluginsSource(); const sourced = ensurePromptSource(); - return changed || sourced; + return changed || pluginsSourced || sourced; }, }, { diff --git a/src/clean.js b/src/clean.js index 91a0153..d00f2b8 100644 --- a/src/clean.js +++ b/src/clean.js @@ -16,6 +16,10 @@ const MANAGED_FILES = [ { path: ".config/zsh/core/options.zsh", templates: [join(CONFIGS_DIR, "core", "options.zsh")] }, { path: ".config/zsh/shared/tools.zsh", templates: [join(CONFIGS_DIR, "shared", "tools.zsh")] }, { path: ".config/zsh/shared/fzf.zsh", templates: [join(CONFIGS_DIR, "shared", "fzf.zsh")] }, + { path: ".config/zsh/shared/completion.zsh", templates: [join(CONFIGS_DIR, "shared", "completion.zsh")] }, + { path: ".config/zsh/shared/highlighting.zsh", templates: [join(CONFIGS_DIR, "shared", "highlighting.zsh")] }, + { path: ".config/zsh/shared/plugins.zsh", templates: [join(CONFIGS_DIR, "shared", "plugins.zsh")] }, + { path: ".config/zsh/shared/aliases.zsh", templates: [join(CONFIGS_DIR, "shared", "aliases.zsh")] }, { path: ".config/zsh/shared/prompt.zsh", templates: [ diff --git a/src/cli-config.js b/src/cli-config.js index e5c693a..4f8d436 100644 --- a/src/cli-config.js +++ b/src/cli-config.js @@ -6,6 +6,7 @@ export function getHelpText(executable = "node src/cli.js") { Run suitup from a zsh session. Commands: + init Non-interactive quick init with recommended defaults setup Full interactive environment setup (default) append Append recommended configs to existing .zshrc verify Verify installation and config integrity @@ -21,6 +22,10 @@ export function resolveCommand(input) { return "setup"; } + if (input === "init") { + return "init"; + } + if (HELP_FLAGS.has(input)) { return "help"; } diff --git a/src/cli.js b/src/cli.js index c21c206..2710e50 100644 --- a/src/cli.js +++ b/src/cli.js @@ -27,6 +27,9 @@ export async function main(argv = process.argv) { case "setup": await runSetup(); break; + case "init": + await runSetup({ defaults: true }); + break; case "append": await runAppend(); break; diff --git a/src/setup.js b/src/setup.js index 9b89023..4a2a9cd 100644 --- a/src/setup.js +++ b/src/setup.js @@ -18,6 +18,14 @@ import { commandExists } from "./utils/shell.js"; import { isZshShell } from "./utils/shell-context.js"; export { isZshShell } from "./utils/shell-context.js"; +export function getRecommendedCliToolValues() { + return [...CLI_TOOLS.essentials, ...CLI_TOOLS.shell].map((tool) => tool.value); +} + +export function getRecommendedAppValues() { + return APPS.recommended.map((app) => app.value); +} + /** * Full interactive setup flow. */ @@ -75,17 +83,20 @@ export function detectCompletedSteps({ existsSync(join(zshConfigDir, "core", "paths.zsh")) && existsSync(join(zshConfigDir, "core", "options.zsh")) && existsSync(join(zshConfigDir, "shared", "tools.zsh")) && + existsSync(join(zshConfigDir, "shared", "fzf.zsh")) && + existsSync(join(zshConfigDir, "shared", "completion.zsh")) && + existsSync(join(zshConfigDir, "shared", "highlighting.zsh")) && existsSync(join(zshConfigDir, "shared", "prompt.zsh")) && existsSync(join(zshConfigDir, "local", "machine.zsh")) ) { completed.add("zsh-config"); } - if (existsSync(join(suitupDir, "zinit-plugins")) || existsSync(zinitHome)) { + if (existsSync(join(zshConfigDir, "shared", "plugins.zsh")) || existsSync(join(suitupDir, "zinit-plugins")) || existsSync(zinitHome)) { completed.add("plugins"); } - if (existsSync(join(suitupDir, "aliases"))) { + if (existsSync(join(zshConfigDir, "shared", "aliases.zsh")) || existsSync(join(suitupDir, "aliases"))) { completed.add("aliases"); } @@ -114,7 +125,7 @@ export function getInitialStepValues(opts = {}) { return getDefaultSteps(opts.platform).filter((step) => !completed.has(step)); } -export async function runSetup() { +export async function runSetup({ defaults = false } = {}) { p.intro(pc.bgCyan(pc.black(" Suit up! "))); if (!isZshShell()) { @@ -131,32 +142,39 @@ export async function runSetup() { p.log.info(`Deselected already configured steps: ${completedSteps.join(", ")}`); } - 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: "recommended zinit or skip" }, - { 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, - }); - - if (p.isCancel(steps)) { + const steps = defaults + ? initialValues + : 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: "recommended zinit or skip" }, + { 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, + }); + + if (!defaults && (p.isCancel(steps) || steps.length === 0)) { p.cancel("Setup cancelled."); process.exit(0); } + if (defaults && steps.length === 0) { + p.outro("Nothing to do."); + return; + } + // --- Step 2: Plugin manager choice (if selected) --- let pluginManager = "zinit"; - if (steps.includes("plugins")) { + if (!defaults && steps.includes("plugins")) { const pmChoice = await p.select({ message: "Choose plugin manager setup:", options: [ @@ -173,7 +191,7 @@ export async function runSetup() { } let promptTheme = "p10k"; - if (steps.includes("zsh-config") || steps.includes("plugins")) { + if (!defaults && (steps.includes("zsh-config") || steps.includes("plugins"))) { const promptChoice = await p.select({ message: "Choose a prompt preset:", options: [ @@ -211,45 +229,53 @@ export async function runSetup() { // --- 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); + if (defaults) { + selectedTools = getRecommendedCliToolValues(); + } else { + 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; } - 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); + if (defaults) { + selectedApps = getRecommendedAppValues(); + } else { + 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; } - selectedApps = appChoice; } // --- Execute selected steps --- p.log.step(pc.bold("Starting installation...")); if (steps.includes("bootstrap")) { - await bootstrap(); + await bootstrap({ defaults }); } if (steps.includes("zsh-config")) { diff --git a/src/steps/aliases.js b/src/steps/aliases.js index 4dc8475..f0683c8 100644 --- a/src/steps/aliases.js +++ b/src/steps/aliases.js @@ -11,12 +11,12 @@ import { CONFIGS_DIR } from "../constants.js"; */ 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); + const dest = join(base, ".config", "zsh", "shared", "aliases.zsh"); + ensureDir(join(base, ".config", "zsh", "shared")); + const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", "aliases.zsh"), dest); if (copied) { - p.log.success("Aliases written to ~/.config/suitup/aliases"); + p.log.success("Aliases written to ~/.config/zsh/shared/aliases.zsh"); } else { - p.log.info("Aliases already exist at ~/.config/suitup/aliases, skipped"); + p.log.info("Aliases already exist at ~/.config/zsh/shared/aliases.zsh, skipped"); } } diff --git a/src/steps/bootstrap.js b/src/steps/bootstrap.js index 9731775..48a8c6b 100644 --- a/src/steps/bootstrap.js +++ b/src/steps/bootstrap.js @@ -1,16 +1,27 @@ import * as p from "@clack/prompts"; import { commandExists, run, runStream } from "../utils/shell.js"; +const BREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'; +const BREW_SHELLENV_COMMAND = "(echo; echo 'eval \"$(/opt/homebrew/bin/brew shellenv)\"') >> ~/.zprofile && eval \"$(/opt/homebrew/bin/brew shellenv)\""; + function isBrewAvailable() { return commandExists("brew"); } -async function ensureBrewOnMac() { +async function ensureBrewOnMac({ defaults = false } = {}) { if (isBrewAvailable()) { p.log.success("Homebrew is already installed"); return true; } + if (defaults) { + p.log.step("Installing Homebrew..."); + await runStream(BREW_INSTALL_COMMAND); + await runStream(BREW_SHELLENV_COMMAND); + p.log.success("Homebrew installed"); + return true; + } + const choice = await p.select({ message: "Homebrew not found. How do you want to continue?", options: [ @@ -26,8 +37,8 @@ async function ensureBrewOnMac() { } 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)"'); + await runStream(BREW_INSTALL_COMMAND); + await runStream(BREW_SHELLENV_COMMAND); p.log.success("Homebrew installed"); return true; } @@ -41,13 +52,18 @@ function detectLinuxManagers() { return managers; } -async function chooseLinuxManager() { +async function chooseLinuxManager({ defaults = false } = {}) { const managers = detectLinuxManagers(); if (managers.length === 0) { p.log.warn("No supported package manager detected. Skipping package manager setup."); return "skip"; } + if (defaults) { + p.log.info(`Using ${managers[0]} for bootstrap`); + return managers[0]; + } + const labels = { "apt-get": "apt-get", dnf: "dnf", @@ -101,14 +117,14 @@ async function installZshViaManager(manager) { /** * Install package manager baseline + Zsh. */ -export async function bootstrap({ platform = process.platform } = {}) { +export async function bootstrap({ platform = process.platform, defaults = false } = {}) { let manager = "skip"; if (platform === "darwin") { - const brewReady = await ensureBrewOnMac(); + const brewReady = await ensureBrewOnMac({ defaults }); manager = brewReady ? "brew" : "skip"; } else if (platform === "linux") { - manager = await chooseLinuxManager(); + manager = await chooseLinuxManager({ defaults }); } else { p.log.warn(`Unsupported platform: ${platform}. Skipping package manager setup.`); } diff --git a/src/steps/plugin-manager.js b/src/steps/plugin-manager.js index 8e36e52..6e44574 100644 --- a/src/steps/plugin-manager.js +++ b/src/steps/plugin-manager.js @@ -30,11 +30,11 @@ export async function installZinit({ home } = {}) { } // 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); + const dest = join(base, ".config", "zsh", "shared", "plugins.zsh"); + ensureDir(join(base, ".config", "zsh", "shared")); + const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", "plugins.zsh"), dest); if (copied) { - p.log.success("zinit plugin config written to ~/.config/suitup/zinit-plugins"); + p.log.success("zinit plugin config written to ~/.config/zsh/shared/plugins.zsh"); } else { p.log.info("zinit plugin config already exists, skipped"); } diff --git a/src/steps/zsh-config.js b/src/steps/zsh-config.js index 76fdd6a..a1b0296 100644 --- a/src/steps/zsh-config.js +++ b/src/steps/zsh-config.js @@ -66,7 +66,12 @@ export async function setupZshConfig({ home, promptTheme = "p10k" } = {}) { } // Copy shared configs (skip if already exist) - const sharedFiles = ["tools.zsh", "fzf.zsh"]; + const sharedFiles = [ + "tools.zsh", + "fzf.zsh", + "completion.zsh", + "highlighting.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/src/verify.js b/src/verify.js index 5852c0d..80dc820 100644 --- a/src/verify.js +++ b/src/verify.js @@ -18,9 +18,12 @@ const CHECKS = { { 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/completion.zsh", label: "~/.config/zsh/shared/completion.zsh" }, + { path: ".config/zsh/shared/highlighting.zsh", label: "~/.config/zsh/shared/highlighting.zsh" }, + { path: ".config/zsh/shared/plugins.zsh", label: "~/.config/zsh/shared/plugins.zsh" }, + { path: ".config/zsh/shared/aliases.zsh", label: "~/.config/zsh/shared/aliases.zsh" }, { path: ".config/zsh/shared/prompt.zsh", label: "~/.config/zsh/shared/prompt.zsh" }, - { path: ".config/suitup/aliases", label: "~/.config/suitup/aliases" }, - { path: ".config/suitup/zinit-plugins", label: "~/.config/suitup/zinit-plugins" }, + { path: ".config/zsh/local/machine.zsh", label: "~/.config/zsh/local/machine.zsh" }, ], tools: [ { cmd: "brew", label: "Homebrew" }, diff --git a/tests/append.test.js b/tests/append.test.js index 4d72362..7f4f587 100644 --- a/tests/append.test.js +++ b/tests/append.test.js @@ -64,7 +64,7 @@ describe("Append mode utilities", () => { const result = appendIfMissing( zshrcPath, - '# >>> suitup/aliases >>>\nsource "$HOME/.config/suitup/aliases"\n# <<< suitup/aliases <<<', + '# >>> suitup/aliases >>>\nsource "$HOME/.config/zsh/shared/aliases.zsh"\n# <<< suitup/aliases <<<', "suitup/aliases" ); @@ -76,12 +76,12 @@ describe("Append mode utilities", () => { test("appendIfMissing skips when marker already present", () => { const original = - '# existing config\n# >>> suitup/aliases >>>\nsource "$HOME/.config/suitup/aliases"\n# <<< suitup/aliases <<<\n'; + '# existing config\n# >>> suitup/aliases >>>\nsource "$HOME/.config/zsh/shared/aliases.zsh"\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 >>>\nsource "$HOME/.config/zsh/shared/aliases.zsh"\n# <<< suitup/aliases <<<', "suitup/aliases" ); diff --git a/tests/bootstrap.test.js b/tests/bootstrap.test.js index 3819e93..2d04d9c 100644 --- a/tests/bootstrap.test.js +++ b/tests/bootstrap.test.js @@ -57,6 +57,24 @@ describe("bootstrap step", () => { expect(runStream).toHaveBeenCalledWith(expect.stringContaining("Homebrew/install")); }); + test("defaults mode installs Homebrew without prompting", 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", defaults: true }); + + expect(p.select).not.toHaveBeenCalled(); + expect(runStream).toHaveBeenCalledWith(expect.stringContaining("Homebrew/install")); + }); + test("installs Zsh with brew on macOS", async () => { commandExists.mockImplementation((name) => { if (name === "brew") return true; @@ -87,6 +105,24 @@ describe("bootstrap step", () => { expect(runStream).toHaveBeenCalledWith(expect.stringContaining("apt-get install -y zsh")); }); + test("defaults mode auto-selects the first detected Linux package manager", async () => { + 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", defaults: true }); + + expect(p.select).not.toHaveBeenCalled(); + expect(runStream).toHaveBeenCalledWith(expect.stringContaining("apt-get install -y zsh")); + }); + test("allows skipping package manager setup", async () => { p.select.mockResolvedValue("skip"); commandExists.mockImplementation((name) => { diff --git a/tests/clean.test.js b/tests/clean.test.js index 15ff057..7ba0a8b 100644 --- a/tests/clean.test.js +++ b/tests/clean.test.js @@ -33,10 +33,12 @@ function writeManagedTree(base) { ["core/options.zsh", ".config/zsh/core/options.zsh"], ["shared/tools.zsh", ".config/zsh/shared/tools.zsh"], ["shared/fzf.zsh", ".config/zsh/shared/fzf.zsh"], + ["shared/completion.zsh", ".config/zsh/shared/completion.zsh"], + ["shared/highlighting.zsh", ".config/zsh/shared/highlighting.zsh"], + ["shared/plugins.zsh", ".config/zsh/shared/plugins.zsh"], + ["shared/aliases.zsh", ".config/zsh/shared/aliases.zsh"], ["shared/prompt.zsh", ".config/zsh/shared/prompt.zsh"], ["local/machine.zsh", ".config/zsh/local/machine.zsh"], - ["aliases", ".config/suitup/aliases"], - ["zinit-plugins", ".config/suitup/zinit-plugins"], ["config.vim", ".config/suitup/config.vim"], ["zshrc.template", ".zshrc"], ["zshenv.template", ".zshenv"], @@ -102,16 +104,16 @@ describe("clean command", () => { 'source_if_exists() { [[ -f "$1" ]] && source "$1"; }', "# <<< suitup/helper <<<", "# >>> suitup/aliases >>>", - 'source_if_exists "$HOME/.config/suitup/aliases"', + 'source_if_exists "$HOME/.config/zsh/shared/aliases.zsh"', "# <<< suitup/aliases <<<", "", ].join("\n"), "utf-8" ); - mkdirSync(join(sandbox.path, ".config", "suitup"), { recursive: true }); + mkdirSync(join(sandbox.path, ".config", "zsh", "shared"), { recursive: true }); writeFileSync( - join(sandbox.path, ".config", "suitup", "aliases"), - readFileSync(join(CONFIGS_DIR, "aliases"), "utf-8"), + join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"), + readFileSync(join(CONFIGS_DIR, "shared", "aliases.zsh"), "utf-8"), "utf-8" ); @@ -121,24 +123,24 @@ describe("clean command", () => { expect(cleaned).toContain("export FOO=bar"); expect(cleaned).not.toContain("suitup/helper"); expect(cleaned).not.toContain("suitup/aliases"); - expect(existsSync(join(sandbox.path, ".config", "suitup"))).toBe(false); + expect(existsSync(join(sandbox.path, ".config", "zsh", "shared"))).toBe(false); expect(summary.cleaned).toContain("~/.zshrc"); }); test("preserves user-modified managed files", () => { mkdirSync(join(sandbox.path, ".config", "zsh", "core"), { recursive: true }); - mkdirSync(join(sandbox.path, ".config", "suitup"), { recursive: true }); + mkdirSync(join(sandbox.path, ".config", "zsh", "shared"), { recursive: true }); writeFileSync(join(sandbox.path, ".config", "zsh", "core", "env.zsh"), "# custom env\n", "utf-8"); - writeFileSync(join(sandbox.path, ".config", "suitup", "aliases"), "# custom aliases\n", "utf-8"); + writeFileSync(join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "# custom aliases\n", "utf-8"); writeFileSync(join(sandbox.path, ".zshenv"), "# my zshenv\nexport FOO=bar\n", "utf-8"); const summary = cleanSandbox(sandbox.path); expect(readFileSync(join(sandbox.path, ".config", "zsh", "core", "env.zsh"), "utf-8")).toBe("# custom env\n"); - expect(readFileSync(join(sandbox.path, ".config", "suitup", "aliases"), "utf-8")).toBe("# custom aliases\n"); + expect(readFileSync(join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "utf-8")).toBe("# custom aliases\n"); expect(readFileSync(join(sandbox.path, ".zshenv"), "utf-8")).toContain("export FOO=bar"); expect(summary.preserved).toContain("~/.config/zsh/core/env.zsh"); - expect(summary.preserved).toContain("~/.config/suitup/aliases"); + expect(summary.preserved).toContain("~/.config/zsh/shared/aliases.zsh"); expect(summary.preserved).toContain("~/.zshenv"); }); diff --git a/tests/cli-main.test.js b/tests/cli-main.test.js index 00004a2..1dda4e5 100644 --- a/tests/cli-main.test.js +++ b/tests/cli-main.test.js @@ -53,6 +53,15 @@ describe("cli main", () => { expect(runSetup).toHaveBeenCalledTimes(1); }); + test("routes init to non-interactive setup defaults", async () => { + const { main } = await import("../src/cli.js"); + + await main(["node", "src/cli.js", "init"]); + + expect(requireZshShell).toHaveBeenCalledWith(); + expect(runSetup).toHaveBeenCalledWith({ defaults: true }); + }); + test("prints help without invoking async commands", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { main } = await import("../src/cli.js"); diff --git a/tests/cli.test.js b/tests/cli.test.js index 0536542..387d500 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -19,6 +19,11 @@ describe("cli command resolution", () => { expect(helpText).toContain("clean"); }); + test("help text lists init", () => { + const helpText = getHelpText(); + expect(helpText).toContain("init"); + }); + test("help text lists migrate-paths", () => { const helpText = getHelpText(); expect(helpText).toContain("migrate-paths"); @@ -32,4 +37,8 @@ describe("cli command resolution", () => { test("resolves migrate-paths command", () => { expect(resolveCommand("migrate-paths")).toBe("migrate-paths"); }); + + test("resolves init command", () => { + expect(resolveCommand("init")).toBe("init"); + }); }); diff --git a/tests/configs.test.js b/tests/configs.test.js index cef36a5..414a043 100644 --- a/tests/configs.test.js +++ b/tests/configs.test.js @@ -29,8 +29,8 @@ const FORBIDDEN_PATTERNS = [ ]; describe("Static config templates", () => { - test("aliases file exists and has content", () => { - const file = join(CONFIGS_DIR, "aliases"); + test("shared/aliases.zsh exists and has content", () => { + const file = join(CONFIGS_DIR, "shared", "aliases.zsh"); expect(existsSync(file)).toBe(true); const content = readFileSync(file, "utf-8"); expect(content.length).toBeGreaterThan(0); @@ -42,34 +42,27 @@ describe("Static config templates", () => { expect(content).toContain("bat"); }); - test("aliases file does not contain private/company content", () => { - const content = readFileSync(join(CONFIGS_DIR, "aliases"), "utf-8"); + test("shared/aliases.zsh does not contain private/company content", () => { + const content = readFileSync(join(CONFIGS_DIR, "shared", "aliases.zsh"), "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"); + test("shared/plugins.zsh exists and has correct content", () => { + const file = join(CONFIGS_DIR, "shared", "plugins.zsh"); expect(existsSync(file)).toBe(true); const content = readFileSync(file, "utf-8"); expect(content).toContain("zinit"); expect(content).toContain("zsh-autosuggestions"); expect(content).toContain("zsh-syntax-highlighting"); - // p10k is loaded last in prompt.zsh, not here - expect(content).not.toContain("powerlevel10k"); + expect(content).toContain("powerlevel10k"); }); - test("shared/prompt.zsh loads p10k theme last (after all other plugins)", () => { + test("shared/prompt.zsh loads p10k config or falls back to a basic prompt", () => { 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"); expect(content).toContain("PROMPT='"); - // p10k theme load must appear before .p10k.zsh source - const themeIdx = content.indexOf("zinit light romkatv/powerlevel10k"); - const configIdx = content.lastIndexOf("~/.p10k.zsh"); - expect(themeIdx).toBeLessThan(configIdx); }); test("shared/prompt-basic.zsh provides a simple fallback prompt", () => { @@ -90,7 +83,7 @@ describe("Static config templates", () => { }); test("shared config files exist", () => { - for (const file of ["tools.zsh", "prompt.zsh", "prompt-basic.zsh", "fzf.zsh"]) { + for (const file of ["tools.zsh", "prompt.zsh", "prompt-basic.zsh", "fzf.zsh", "aliases.zsh", "plugins.zsh", "completion.zsh", "highlighting.zsh"]) { expect(existsSync(join(CONFIGS_DIR, "shared", file))).toBe(true); } }); @@ -155,6 +148,28 @@ describe("Static config templates", () => { expect(content).toContain("fzf.zsh"); }); + test("shared/completion.zsh configures cached compinit", () => { + const content = readFileSync(join(CONFIGS_DIR, "shared", "completion.zsh"), "utf-8"); + expect(content).toContain("compinit"); + expect(content).toContain(".zcompdump"); + expect(content).toContain("expand-or-complete"); + }); + + test("shared/highlighting.zsh defines syntax highlighting styles", () => { + const content = readFileSync(join(CONFIGS_DIR, "shared", "highlighting.zsh"), "utf-8"); + expect(content).toContain("ZSH_HIGHLIGHT_STYLES"); + expect(content).toContain("reserved-word"); + }); + + test("local/machine.zsh stays machine-local and avoids work-specific examples", () => { + const content = readFileSync(join(CONFIGS_DIR, "local", "machine.zsh"), "utf-8"); + expect(content).not.toContain("work/*"); + expect(content).not.toContain("ZSH_WORK_PROFILE"); + for (const pattern of FORBIDDEN_PATTERNS) { + expect(content).not.toContain(pattern); + } + }); + test("shared/tools.zsh loads fzf before atuin so atuin keeps the Ctrl-R binding", () => { const content = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); const fzfIdx = content.indexOf("_source_cached_tool_init fzf-init fzf 'fzf --zsh'"); @@ -173,6 +188,10 @@ describe("Static config templates", () => { "core/options.zsh", "shared/tools.zsh", "shared/fzf.zsh", + "shared/completion.zsh", + "shared/highlighting.zsh", + "shared/plugins.zsh", + "shared/aliases.zsh", "shared/prompt.zsh", "local/machine.zsh", ]; @@ -200,14 +219,16 @@ describe("Static config templates", () => { test("no hardcoded home directory paths in any config", () => { const allFiles = [ - "aliases", - "zinit-plugins", "core/perf.zsh", "core/env.zsh", "core/paths.zsh", "core/options.zsh", "shared/tools.zsh", "shared/fzf.zsh", + "shared/completion.zsh", + "shared/highlighting.zsh", + "shared/plugins.zsh", + "shared/aliases.zsh", "shared/prompt.zsh", "zshrc.template", "zshenv.template", diff --git a/tests/install-script.test.js b/tests/install-script.test.js index 15e09a5..440b72f 100644 --- a/tests/install-script.test.js +++ b/tests/install-script.test.js @@ -16,37 +16,53 @@ describe("install.sh", () => { }).not.toThrow(); }); - test("downloads the repo archive, installs dependencies, and launches under zsh with terminal input", () => { + test("bootstraps prerequisites, installs dependencies, and launches under zsh", () => { const content = readFileSync(INSTALL_SCRIPT, "utf-8"); expect(content).toContain("https://github.com/${REPO_SLUG}/archive/refs/heads/${SUITUP_REF}.tar.gz"); + expect(content).toContain('ensure_zsh "${PACKAGE_MANAGER}"'); + expect(content).toContain('ensure_node_runtime "${PACKAGE_MANAGER}"'); expect(content).toContain("npm ci --no-fund --no-audit"); - expect(content).toContain("require_cmd zsh"); expect(content).toContain("zsh -lc"); - expect(content).toContain("< /dev/tty"); + expect(content).toContain("launch_cli"); }); - test("prompts for init or append mode before launching when no command is provided", () => { + test("defaults to quick init when no command is provided", () => { const content = readFileSync(INSTALL_SCRIPT, "utf-8"); - expect(content).toContain("Choose install mode:"); - expect(content).toContain("1) init"); - expect(content).toContain("2) append"); - expect(content).toContain('CLI_COMMAND="${1:-}"'); - expect(content).toContain('read -r -p "Select [1-2] (default 1): " INSTALL_MODE < /dev/tty'); + expect(content).toContain('CLI_COMMAND="${1:-init}"'); + expect(content).not.toContain("Choose install mode:"); }); - test("maps init to setup and forwards the selected command to the CLI", () => { + test("passes init directly to the CLI and validates supported commands", () => { const content = readFileSync(INSTALL_SCRIPT, "utf-8"); - expect(content).toContain('if [[ "${CLI_COMMAND}" == "init" ]]; then'); - expect(content).toContain('CLI_COMMAND="setup"'); - expect(content).toContain('"${CLI_COMMAND}" "$@" < /dev/tty'); + expect(content).toContain('case "${CLI_COMMAND}" in'); + expect(content).toContain('init|setup|append|verify|clean|migrate-paths|help|--help|-h'); + expect(content).toContain('launch_cli "${WORK_DIR}/repo" "${CLI_COMMAND}" "$@"'); }); - test("prints a helpful error for invalid installer mode selections", () => { + test("prints a helpful error for unknown installer commands", () => { const content = readFileSync(INSTALL_SCRIPT, "utf-8"); - expect(content).toContain('Please enter 1 for init or 2 for append.'); + expect(content).toContain('Unknown command: ${CLI_COMMAND}'); + }); + + test("skips Node.js installation when a compatible version is already present", () => { + const content = readFileSync(INSTALL_SCRIPT, "utf-8"); + + // ensure_node_runtime returns early when node + npm are found at version >= 20 + expect(content).toContain("if have_cmd node && have_cmd npm;"); + expect(content).toMatch(/"\$\{major\}" -ge 20/); + expect(content).toContain("return 0"); + }); + + test("uses NodeSource repository for reliable Node.js installation on Linux", () => { + const content = readFileSync(INSTALL_SCRIPT, "utf-8"); + + expect(content).toContain("https://deb.nodesource.com/setup_lts.x"); + expect(content).toContain("https://rpm.nodesource.com/setup_lts.x"); + // NodeSource setup scripts are piped into bash with sudo + expect(content).toMatch(/curl.*nodesource.*\| sudo -E bash -/s); }); }); diff --git a/tests/plugin-manager.test.js b/tests/plugin-manager.test.js index a0cf213..7e5e9cf 100644 --- a/tests/plugin-manager.test.js +++ b/tests/plugin-manager.test.js @@ -41,7 +41,7 @@ describe("plugin-manager step", () => { expect.stringContaining("zinit") ); // Plugin config should be written - expect(existsSync(join(sandbox.path, ".config", "suitup", "zinit-plugins"))).toBe(true); + expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "plugins.zsh"))).toBe(true); }); test("skips zinit install when directory already exists", async () => { @@ -57,14 +57,14 @@ describe("plugin-manager step", () => { // 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"); + mkdirSync(join(sandbox.path, ".config", "zsh", "shared"), { recursive: true }); + writeFileSync(join(sandbox.path, ".config", "zsh", "shared", "plugins.zsh"), "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"); + const content = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "plugins.zsh"), "utf-8"); expect(content).toBe("existing"); }); diff --git a/tests/setup.test.js b/tests/setup.test.js index 2a575ab..4efa34e 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -11,7 +11,14 @@ import { import { join } from "node:path"; import { tmpdir } from "node:os"; import { createSandbox } from "./helpers.js"; -import { detectCompletedSteps, getDefaultSteps, getInitialStepValues, isZshShell } from "../src/setup.js"; +import { + detectCompletedSteps, + getDefaultSteps, + getInitialStepValues, + getRecommendedAppValues, + getRecommendedCliToolValues, + isZshShell, +} from "../src/setup.js"; const CONFIGS_DIR = join(import.meta.dirname, "..", "configs"); @@ -32,7 +39,6 @@ describe("Setup simulation in sandbox", () => { ".config/zsh/core", ".config/zsh/shared", ".config/zsh/local", - ".config/suitup", ]; for (const dir of dirs) { mkdirSync(join(sandbox, dir), { recursive: true }); @@ -47,21 +53,16 @@ describe("Setup simulation in sandbox", () => { } // Copy shared configs - for (const file of ["tools.zsh", "prompt.zsh"]) { + for (const file of ["tools.zsh", "fzf.zsh", "prompt.zsh", "completion.zsh", "highlighting.zsh", "aliases.zsh", "plugins.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") + join(CONFIGS_DIR, "local", "machine.zsh"), + join(sandbox, ".config/zsh/local", "machine.zsh") ); // Copy .zshrc @@ -78,9 +79,13 @@ describe("Setup simulation in sandbox", () => { ".config/zsh/core/paths.zsh", ".config/zsh/core/options.zsh", ".config/zsh/shared/tools.zsh", + ".config/zsh/shared/fzf.zsh", + ".config/zsh/shared/completion.zsh", + ".config/zsh/shared/highlighting.zsh", + ".config/zsh/shared/aliases.zsh", + ".config/zsh/shared/plugins.zsh", ".config/zsh/shared/prompt.zsh", - ".config/suitup/aliases", - ".config/suitup/zinit-plugins", + ".config/zsh/local/machine.zsh", ]; for (const file of expectedFiles) { @@ -99,15 +104,15 @@ describe("Setup simulation in sandbox", () => { 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/plugins.zsh"); + expect(content).toContain("shared/highlighting.zsh"); + expect(content).toContain("shared/aliases.zsh"); + expect(content).toContain("shared/completion.zsh"); expect(content).toContain("shared/prompt.zsh"); expect(content).toContain("_zsh_report"); expect(content).toContain('source_if_exists "${ZINIT_HOME}/zinit.zsh"'); - // prompt.zsh (which loads p10k last) must come after zinit-plugins - const pluginsIdx = content.indexOf("suitup/zinit-plugins"); + const pluginsIdx = content.indexOf("shared/plugins.zsh"); const promptIdx = content.indexOf("shared/prompt.zsh"); const reportIdx = content.indexOf("_zsh_report"); expect(pluginsIdx).toBeLessThan(promptIdx); @@ -125,11 +130,30 @@ describe("Setup simulation in sandbox", () => { expect(getDefaultSteps("darwin")).toContain("apps"); }); + test("quick init uses recommended CLI tools only", () => { + expect(getRecommendedCliToolValues()).toEqual([ + "bat", + "eza", + "fzf", + "fd", + "atuin", + "zoxide", + "ripgrep", + ]); + }); + + test("quick init uses recommended GUI apps only", () => { + expect(getRecommendedAppValues()).toEqual([ + "iterm2", + "raycast", + "visual-studio-code", + ]); + }); + test("detects completed suitup-managed setup steps", () => { mkdirSync(join(sandbox, ".config", "zsh", "core"), { recursive: true }); mkdirSync(join(sandbox, ".config", "zsh", "shared"), { recursive: true }); mkdirSync(join(sandbox, ".config", "zsh", "local"), { recursive: true }); - mkdirSync(join(sandbox, ".config", "suitup"), { recursive: true }); mkdirSync(join(sandbox, ".local", "share", "zinit", "zinit.git"), { recursive: true }); writeFileSync(join(sandbox, ".zshrc"), "# Generated by suitup\n", "utf-8"); @@ -139,10 +163,13 @@ describe("Setup simulation in sandbox", () => { writeFileSync(join(sandbox, ".config", "zsh", "core", "paths.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "core", "options.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "shared", "tools.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "fzf.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "completion.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "highlighting.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "plugins.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "aliases.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "shared", "prompt.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); - writeFileSync(join(sandbox, ".config", "suitup", "zinit-plugins"), "", "utf-8"); - writeFileSync(join(sandbox, ".config", "suitup", "aliases"), "", "utf-8"); const detected = detectCompletedSteps({ home: sandbox, @@ -167,7 +194,6 @@ describe("Setup simulation in sandbox", () => { mkdirSync(join(completedSandbox.path, ".config", "zsh", "core"), { recursive: true }); mkdirSync(join(completedSandbox.path, ".config", "zsh", "shared"), { recursive: true }); mkdirSync(join(completedSandbox.path, ".config", "zsh", "local"), { recursive: true }); - mkdirSync(join(completedSandbox.path, ".config", "suitup"), { recursive: true }); writeFileSync(join(completedSandbox.path, ".zshrc"), "# Generated by suitup\n", "utf-8"); writeFileSync(join(completedSandbox.path, ".zshenv"), "# Generated by suitup\n", "utf-8"); @@ -176,9 +202,12 @@ describe("Setup simulation in sandbox", () => { writeFileSync(join(completedSandbox.path, ".config", "zsh", "core", "paths.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "core", "options.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "tools.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "fzf.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "completion.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "highlighting.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "prompt.zsh"), "", "utf-8"); writeFileSync(join(completedSandbox.path, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); - writeFileSync(join(completedSandbox.path, ".config", "suitup", "aliases"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "", "utf-8"); const initialSteps = getInitialStepValues({ home: completedSandbox.path, @@ -209,6 +238,9 @@ describe("Setup simulation in sandbox", () => { writeFileSync(join(sandbox, ".config", "zsh", "core", "paths.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "core", "options.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "shared", "tools.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "fzf.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "completion.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "shared", "highlighting.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "shared", "prompt.zsh"), "", "utf-8"); writeFileSync(join(sandbox, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); @@ -224,7 +256,7 @@ describe("Setup simulation in sandbox", () => { }); test("aliases file uses $HOME or ~ instead of hardcoded paths", () => { - const content = readFileSync(join(CONFIGS_DIR, "aliases"), "utf-8"); + const content = readFileSync(join(CONFIGS_DIR, "shared", "aliases.zsh"), "utf-8"); // Should use ~ or $HOME, not /Users/something expect(content).toContain("~/.zshrc"); diff --git a/tests/verify.test.js b/tests/verify.test.js index aa4a7e3..1e5dd77 100644 --- a/tests/verify.test.js +++ b/tests/verify.test.js @@ -35,7 +35,7 @@ describe("Verify in sandbox", () => { const dirs = [ ".config/zsh/core", ".config/zsh/shared", - ".config/suitup", + ".config/zsh/local", ]; for (const dir of dirs) { mkdirSync(join(sandbox, dir), { recursive: true }); @@ -48,9 +48,12 @@ describe("Verify in sandbox", () => { ["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/completion.zsh", ".config/zsh/shared/completion.zsh"], + ["shared/highlighting.zsh", ".config/zsh/shared/highlighting.zsh"], + ["shared/plugins.zsh", ".config/zsh/shared/plugins.zsh"], + ["shared/aliases.zsh", ".config/zsh/shared/aliases.zsh"], ["shared/prompt.zsh", ".config/zsh/shared/prompt.zsh"], - ["aliases", ".config/suitup/aliases"], - ["zinit-plugins", ".config/suitup/zinit-plugins"], + ["local/machine.zsh", ".config/zsh/local/machine.zsh"], ]; for (const [src, dest] of fileMappings) { @@ -81,6 +84,10 @@ describe("Verify in sandbox", () => { ["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/completion.zsh", ".config/zsh/shared/completion.zsh"], + ["shared/highlighting.zsh", ".config/zsh/shared/highlighting.zsh"], + ["shared/plugins.zsh", ".config/zsh/shared/plugins.zsh"], + ["shared/aliases.zsh", ".config/zsh/shared/aliases.zsh"], ["shared/prompt.zsh", ".config/zsh/shared/prompt.zsh"], ]; diff --git a/tests/zsh-config-steps.test.js b/tests/zsh-config-steps.test.js index e7fef3f..45fc572 100644 --- a/tests/zsh-config-steps.test.js +++ b/tests/zsh-config-steps.test.js @@ -40,6 +40,8 @@ describe("zsh-config step", () => { 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", "completion.zsh"))).toBe(true); + expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "highlighting.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "prompt.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "local", "machine.zsh"))).toBe(true); }); @@ -50,6 +52,8 @@ 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"); + const completion = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "completion.zsh"), "utf-8"); + const highlighting = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "highlighting.zsh"), "utf-8"); expect(perf).toContain("EPOCHREALTIME"); expect(perf).toContain("_record_stage_duration"); @@ -57,6 +61,8 @@ describe("zsh-config step", () => { expect(tools).toContain("$_zsh_tools_cache_dir"); expect(fzf).toContain("FZF_DEFAULT_COMMAND"); expect(fzf).toContain("FZF_CTRL_T_OPTS"); + expect(completion).toContain("compinit"); + expect(highlighting).toContain("ZSH_HIGHLIGHT_STYLES"); }); test("writes a basic prompt preset when selected", async () => { @@ -203,16 +209,16 @@ describe("aliases step", () => { test("writes aliases file in empty sandbox", async () => { await setupAliases({ home: sandbox.path }); - expect(existsSync(join(sandbox.path, ".config", "suitup", "aliases"))).toBe(true); + expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"))).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"); + mkdirSync(join(sandbox.path, ".config", "zsh", "shared"), { recursive: true }); + writeFileSync(join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "# my aliases", "utf-8"); await setupAliases({ home: sandbox.path }); - const content = readFileSync(join(sandbox.path, ".config", "suitup", "aliases"), "utf-8"); + const content = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "aliases.zsh"), "utf-8"); expect(content).toBe("# my aliases"); }); });