From 9b5f0599ac7d136f6de7b09eee3bd6aa224d7dd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:24:33 +0000 Subject: [PATCH 1/5] Initial plan From 6636cbafe3b46d2071467d931ca9d0513fd2d502 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:28:29 +0000 Subject: [PATCH 2/5] fix: keep fnm on path after setup Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- README.md | 2 +- README.zh-CN.md | 2 +- configs/core/paths.zsh | 18 ++++++++++++------ tests/configs.test.js | 4 +++- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 39236cd..bf69dbc 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Bootstrap details: - Linux: choose `apt-get`, `dnf`, `yum`, `brew`, or skip - If Homebrew is already installed in a non-default location, suitup now tries common shellenv paths automatically during Zsh startup - Suitup now also writes a minimal `~/.zshenv` so non-interactive shells can still load shared env vars and PATH setup -- When fnm installs Node.js, suitup sets the installed version as the fnm default so `node`, `npm`, and globally installed CLIs resolve from the fnm-managed location in both interactive and non-interactive shells +- When fnm installs Node.js, suitup keeps both the `fnm` binary and the installed default Node version on PATH so `fnm`, `node`, `npm`, and globally installed CLIs resolve correctly in both interactive and non-interactive shells ### Append diff --git a/README.zh-CN.md b/README.zh-CN.md index ac96768..c9a5b9b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -104,7 +104,7 @@ Bootstrap 细节: - Linux:可选 `apt-get`、`dnf`、`yum`、`brew`,或直接跳过 - 如果 Homebrew 已经安装在非默认位置,suitup 现在会在 Zsh 启动时自动尝试常见 `shellenv` 路径 - suitup 现在也会生成一个精简的 `~/.zshenv`,保证非交互式 shell 也能加载共享环境变量和 PATH -- 当 fnm 安装 Node.js 后,suitup 会把该版本设置为 fnm 默认版本,确保交互式/非交互式 shell 下的 `node`、`npm` 和全局 CLI 都优先指向 fnm 管理的路径 +- 当 fnm 安装 Node.js 后,suitup 会把 `fnm` 自身和该默认 Node 版本一起放进 PATH,确保交互式/非交互式 shell 下的 `fnm`、`node`、`npm` 和全局 CLI 都优先指向 fnm 管理的路径 ### Append(追加) diff --git a/configs/core/paths.zsh b/configs/core/paths.zsh index 4149dd6..197dc43 100644 --- a/configs/core/paths.zsh +++ b/configs/core/paths.zsh @@ -16,14 +16,20 @@ do done unset _suitup_brew_bin -# fnm (Fast Node Manager) — expose the default Node installation to all -# shells so that globally-installed CLIs (pnpm, git-cz …) work in -# non-interactive contexts such as scripts, editors, agents, and git hooks. -# Interactive shells get the full fnm env from shared/tools.zsh instead. -_suitup_fnm_default_bin="${FNM_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fnm}/aliases/default/bin" +# fnm (Fast Node Manager) — keep the fnm binary itself on PATH after suitup +# rewrites ~/.zshrc, then expose the default Node installation to all shells +# so globally-installed CLIs (pnpm, git-cz …) work in non-interactive +# contexts such as scripts, editors, agents, and git hooks. Interactive +# shells get the full fnm env from shared/tools.zsh instead. +_suitup_fnm_dir="${FNM_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fnm}" +if [[ -d "$_suitup_fnm_dir" && ":${PATH}:" != *":${_suitup_fnm_dir}:"* ]]; then + export PATH="${_suitup_fnm_dir}:${PATH}" +fi + +_suitup_fnm_default_bin="${_suitup_fnm_dir}/aliases/default/bin" if [[ -d "$_suitup_fnm_default_bin" && ":${PATH}:" != *":${_suitup_fnm_default_bin}:"* ]]; then export PATH="${_suitup_fnm_default_bin}:${PATH}" fi -unset _suitup_fnm_default_bin +unset _suitup_fnm_dir _suitup_fnm_default_bin # Keep this file for user PATH overrides if needed. diff --git a/tests/configs.test.js b/tests/configs.test.js index b008af8..cef36a5 100644 --- a/tests/configs.test.js +++ b/tests/configs.test.js @@ -122,9 +122,11 @@ describe("Static config templates", () => { expect(content).toContain("shellenv zsh"); }); - test("core/paths.zsh exposes fnm default node for non-interactive shells", () => { + test("core/paths.zsh keeps fnm and its default node on PATH for non-interactive shells", () => { const content = readFileSync(join(CONFIGS_DIR, "core", "paths.zsh"), "utf-8"); expect(content).toContain("fnm"); + expect(content).toContain("_suitup_fnm_dir"); + expect(content).toContain(".local/share}/fnm"); expect(content).toContain("aliases/default/bin"); // Should check for PATH deduplication expect(content).toContain(":${PATH}:"); From dfdd714676ca7e8bee0b4c05cd254f8c66b18430 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:47:56 +0000 Subject: [PATCH 3/5] fix: handle fnm install fallback and setup defaults Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- README.md | 9 +++- README.zh-CN.md | 9 +++- install.sh | 27 ++++++++++- src/setup.js | 89 +++++++++++++++++++++++++++++++++++- src/steps/frontend.js | 48 +++++++++++++++---- tests/frontend.test.js | 35 +++++++++++++- tests/install-script.test.js | 18 ++++++++ tests/setup.test.js | 76 +++++++++++++++++++++++++++++- 8 files changed, 295 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bf69dbc..979295c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Suitup can bootstrap Zsh and Homebrew for you, but the most reliable path is to - Recommended: install Homebrew first so later package/tool steps run in a known-good environment - Optional: if you skip either one, keep the `Bootstrap` step selected and let suitup set them up for you - If your setup stopped halfway, run `node src/cli.js append` to add missing blocks or switch the prompt preset without replacing your whole `.zshrc` +- When suitup detects existing suitup-managed config or already-installed frontend prerequisites, setup now deselects those completed steps by default so reruns stay focused ### Install and run @@ -47,7 +48,7 @@ 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 downloads a temporary copy of the repo, runs `npm ci`, and then launches `node src/cli.js` inside `zsh`. +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`. You can also pass a specific command to the installer: @@ -55,6 +56,12 @@ You can also pass a specific command to the installer: curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- clean ``` +If you want append mode directly without the prompt: + +```bash +curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- append +``` + ### Clone locally ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index c9a5b9b..6768a1c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,6 +36,7 @@ Suitup 可以帮你初始化 Zsh 和 Homebrew,但更稳妥的路径仍然是 - 推荐:先安装 Homebrew,这样后续包管理和工具安装会更稳定 - 可选:如果你不想手动准备,也可以保留 `Bootstrap` 步骤,让 suitup 代为安装 - 如果初始化做到一半中断了,可以运行 `node src/cli.js append` 继续补齐缺失配置,或者切换 prompt 预设,而不必整体重写 `.zshrc` +- 如果 suitup 检测到本地已经存在 suitup 管理的配置,或者前端工具链已经装好,setup 现在会默认把这些已完成步骤反选掉,方便你只补剩余内容 ### 安装并运行 @@ -47,7 +48,7 @@ suitup 现在默认你已经安装好 zsh,并且当前就在 zsh 会话里运 curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash ``` -这个安装脚本会临时下载仓库、执行 `npm ci`,然后在 `zsh` 中启动 `node src/cli.js`。 +这个安装脚本会先询问你要走 `init`(完整 setup)还是 `append`(给现有 `.zshrc` 做增量补充),然后临时下载仓库、执行 `npm ci`,最后在 `zsh` 中启动对应的 `node src/cli.js` 命令。 如果你想直接执行某个命令,也可以这样传参: @@ -55,6 +56,12 @@ curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- clean ``` +如果你想跳过提示、直接进入 append 模式,也可以这样运行: + +```bash +curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- append +``` + ### 本地 clone 运行 ```bash diff --git a/install.sh b/install.sh index 2116607..6f18dc6 100755 --- a/install.sh +++ b/install.sh @@ -37,6 +37,31 @@ if (major < 18) { } ' +CLI_COMMAND="${1:-}" +if [[ -n "${CLI_COMMAND}" ]]; then + shift + if [[ "${CLI_COMMAND}" == "init" ]]; then + CLI_COMMAND="setup" + 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" + ;; + 2) + CLI_COMMAND="append" + ;; + *) + echo "Invalid selection: ${INSTALL_MODE}" >&2 + exit 1 + ;; + esac +fi + echo "Downloading ${REPO_SLUG}@${SUITUP_REF}..." curl --fail --show-error --silent --location "${ARCHIVE_URL}" --output "${ARCHIVE_PATH}" @@ -49,4 +74,4 @@ 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" "$@" < /dev/tty +zsh -lc 'cd "$1" && shift && node src/cli.js "$@"' -- "${WORK_DIR}/repo" "${CLI_COMMAND}" "$@" < /dev/tty diff --git a/src/setup.js b/src/setup.js index 3126e52..8f6b30b 100644 --- a/src/setup.js +++ b/src/setup.js @@ -13,6 +13,8 @@ import { setupVim } from "./steps/vim.js"; import { setupAliases } from "./steps/aliases.js"; import { cleanDock } from "./steps/dock.js"; import { setupZshConfig, writeZshrc, writeZshenv } from "./steps/zsh-config.js"; +import { readFileSafe } from "./utils/fs.js"; +import { commandExists } from "./utils/shell.js"; import { isZshShell } from "./utils/shell-context.js"; export { isZshShell } from "./utils/shell-context.js"; @@ -31,6 +33,85 @@ export function getDefaultSteps(platform = process.platform) { ]; } +function hasCompletedBootstrap(platform, commandExistsFn) { + if (!commandExistsFn("zsh")) { + return false; + } + + if (platform === "darwin") { + return commandExistsFn("brew"); + } + + if (platform === "linux") { + return ["apt-get", "dnf", "yum", "brew"].some((manager) => commandExistsFn(manager)); + } + + return false; +} + +export function detectCompletedSteps({ + home = homedir(), + platform = process.platform, + commandExistsFn = commandExists, +} = {}) { + const completed = new Set(); + const zshConfigDir = join(home, ".config", "zsh"); + const suitupDir = join(home, ".config", "suitup"); + const xdgDataHome = process.env.XDG_DATA_HOME || join(home, ".local", "share"); + const zinitHome = join(xdgDataHome, "zinit", "zinit.git"); + const vimrc = readFileSafe(join(home, ".vimrc")); + + if (hasCompletedBootstrap(platform, commandExistsFn)) { + completed.add("bootstrap"); + } + + if ( + existsSync(join(home, ".zshrc")) && + existsSync(join(home, ".zshenv")) && + existsSync(join(zshConfigDir, "core", "perf.zsh")) && + existsSync(join(zshConfigDir, "core", "env.zsh")) && + existsSync(join(zshConfigDir, "core", "paths.zsh")) && + existsSync(join(zshConfigDir, "core", "options.zsh")) && + existsSync(join(zshConfigDir, "shared", "tools.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)) { + completed.add("plugins"); + } + + if (existsSync(join(suitupDir, "aliases"))) { + completed.add("aliases"); + } + + if ( + commandExistsFn("fnm") && + commandExistsFn("node") && + commandExistsFn("pnpm") && + commandExistsFn("git-cz") + ) { + completed.add("frontend"); + } + + if (existsSync(join(home, ".ssh", "github_rsa"))) { + completed.add("ssh"); + } + + if (existsSync(join(suitupDir, "config.vim")) && vimrc.includes("config.vim")) { + completed.add("vim"); + } + + return [...completed]; +} + +export function getInitialStepValues(opts = {}) { + const completed = new Set(detectCompletedSteps(opts)); + return getDefaultSteps(opts.platform).filter((step) => !completed.has(step)); +} + export async function runSetup() { p.intro(pc.bgCyan(pc.black(" Suit up! "))); @@ -42,6 +123,12 @@ export async function runSetup() { } // --- Step 1: Select setup steps --- + const completedSteps = detectCompletedSteps(); + const initialValues = getInitialStepValues(); + if (completedSteps.length > 0) { + p.log.info(`Deselected already configured steps: ${completedSteps.join(", ")}`); + } + const steps = await p.multiselect({ message: "Select setup steps:", required: true, @@ -57,7 +144,7 @@ export async function runSetup() { { value: "vim", label: "Vim Config", hint: "basic vim setup" }, { value: "dock", label: "Dock Cleanup", hint: "clean macOS Dock" }, ], - initialValues: getDefaultSteps(), + initialValues, }); if (p.isCancel(steps)) { diff --git a/src/steps/frontend.js b/src/steps/frontend.js index 5747647..c2fb89e 100644 --- a/src/steps/frontend.js +++ b/src/steps/frontend.js @@ -1,17 +1,41 @@ import * as p from "@clack/prompts"; -import { commandExists, run, runStream } from "../utils/shell.js"; +import { brewInstall, commandExists, run, runStream } from "../utils/shell.js"; + +async function runStreamChecked(cmd) { + const exitCode = await runStream(cmd); + if (exitCode !== 0) { + throw new Error(`Command exited with status ${exitCode}`); + } +} /** * Install fnm (Fast Node Manager) and set up Node.js + pnpm. */ export async function installFrontendTools() { + let fnmReady = commandExists("fnm"); + // fnm - if (commandExists("fnm")) { + if (fnmReady) { 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"); + try { + await runStreamChecked("curl -fsSL https://fnm.vercel.app/install | bash"); + p.log.success("fnm installed"); + fnmReady = true; + } catch { + if (commandExists("brew")) { + p.log.warn("Could not install fnm via curl, trying Homebrew..."); + if (brewInstall("fnm")) { + p.log.success("fnm installed via Homebrew"); + fnmReady = true; + } else { + p.log.warn("Could not install fnm via curl or Homebrew"); + } + } else { + p.log.warn("Could not install fnm via curl, and Homebrew is not available"); + } + } } // Fetch latest LTS version @@ -27,12 +51,16 @@ export async function installFrontendTools() { } // Install Node via fnm - p.log.step(`Installing Node.js v${ltsVersion} via fnm...`); - try { - await runStream(`fnm install ${ltsVersion} && fnm use ${ltsVersion} && fnm default ${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"); + if (fnmReady || commandExists("fnm")) { + p.log.step(`Installing Node.js v${ltsVersion} via fnm...`); + try { + await runStreamChecked(`fnm install ${ltsVersion} && fnm use ${ltsVersion} && fnm default ${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"); + } + } else { + p.log.warn("Skipping Node.js install because fnm is unavailable"); } // pnpm diff --git a/tests/frontend.test.js b/tests/frontend.test.js index da7ff50..027c917 100644 --- a/tests/frontend.test.js +++ b/tests/frontend.test.js @@ -14,7 +14,8 @@ vi.mock("../src/utils/shell.js", () => ({ })); import { installFrontendTools } from "../src/steps/frontend.js"; -import { commandExists, run, runStream } from "../src/utils/shell.js"; +import { brewInstall, commandExists, run, runStream } from "../src/utils/shell.js"; +import * as p from "@clack/prompts"; describe("frontend step", () => { beforeEach(() => { @@ -48,6 +49,38 @@ describe("frontend step", () => { expect(calls.some((c) => c.includes("fnm.vercel.app"))).toBe(true); }); + test("falls back to Homebrew when fnm curl install fails", async () => { + commandExists.mockImplementation((name) => { + if (name === "fnm") return false; + if (name === "brew") return true; + return true; + }); + runStream.mockImplementationOnce(() => Promise.resolve(22)); + brewInstall.mockReturnValue(true); + + await installFrontendTools(); + + expect(brewInstall).toHaveBeenCalledWith("fnm"); + expect(p.log.success).not.toHaveBeenCalledWith("fnm installed"); + expect(p.log.warn).toHaveBeenCalledWith("Could not install fnm via curl, trying Homebrew..."); + expect(p.log.success).toHaveBeenCalledWith("fnm installed via Homebrew"); + }); + + test("warns when fnm curl install fails and Homebrew is unavailable", async () => { + commandExists.mockImplementation((name) => { + if (name === "fnm" || name === "brew") return false; + return true; + }); + runStream.mockImplementationOnce(() => Promise.resolve(22)); + + await installFrontendTools(); + + expect(brewInstall).not.toHaveBeenCalled(); + expect(p.log.success).not.toHaveBeenCalledWith("fnm installed"); + expect(p.log.warn).toHaveBeenCalledWith("Could not install fnm via curl, and Homebrew is not available"); + expect(p.log.warn).toHaveBeenCalledWith("Skipping Node.js install because fnm is unavailable"); + }); + test("sets fnm default after installing node", async () => { commandExists.mockReturnValue(true); diff --git a/tests/install-script.test.js b/tests/install-script.test.js index 941ec9e..773c0ba 100644 --- a/tests/install-script.test.js +++ b/tests/install-script.test.js @@ -25,4 +25,22 @@ describe("install.sh", () => { expect(content).toContain("zsh -lc"); expect(content).toContain("< /dev/tty"); }); + + test("prompts for init or append mode before launching 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'); + }); + + test("maps init to setup and forwards the selected command to the CLI", () => { + 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'); + }); }); diff --git a/tests/setup.test.js b/tests/setup.test.js index cfd6609..28e8269 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -5,11 +5,13 @@ import { mkdirSync, copyFileSync, readFileSync, + writeFileSync, existsSync, } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { getDefaultSteps, isZshShell } from "../src/setup.js"; +import { createSandbox } from "./helpers.js"; +import { detectCompletedSteps, getDefaultSteps, getInitialStepValues, isZshShell } from "../src/setup.js"; const CONFIGS_DIR = join(import.meta.dirname, "..", "configs"); @@ -123,6 +125,78 @@ describe("Setup simulation in sandbox", () => { expect(getDefaultSteps("darwin")).toContain("apps"); }); + test("detects completed suitup-managed setup steps from sandbox files", () => { + 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"); + writeFileSync(join(sandbox, ".zshenv"), "# Generated by suitup\n", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "core", "perf.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "core", "env.zsh"), "", "utf-8"); + 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", "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, + platform: "darwin", + commandExistsFn(name) { + return ["zsh", "brew", "fnm", "node", "pnpm", "git-cz"].includes(name); + }, + }); + + expect(detected).toEqual(expect.arrayContaining([ + "bootstrap", + "zsh-config", + "plugins", + "aliases", + "frontend", + ])); + }); + + test("removes completed steps from initial setup selections", () => { + const completedSandbox = createSandbox(); + try { + 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"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "core", "perf.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "core", "env.zsh"), "", "utf-8"); + 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", "prompt.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); + writeFileSync(join(completedSandbox.path, ".config", "suitup", "aliases"), "", "utf-8"); + + const initialSteps = getInitialStepValues({ + home: completedSandbox.path, + platform: "darwin", + commandExistsFn(name) { + return ["zsh", "brew"].includes(name); + }, + }); + + expect(initialSteps).not.toContain("bootstrap"); + expect(initialSteps).not.toContain("zsh-config"); + expect(initialSteps).not.toContain("aliases"); + expect(initialSteps).toContain("frontend"); + } finally { + completedSandbox.cleanup(); + } + }); + test("aliases file uses $HOME or ~ instead of hardcoded paths", () => { const content = readFileSync(join(CONFIGS_DIR, "aliases"), "utf-8"); From 3077f1724dbcbd358d15545b2f5b64fa8b36a823 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:49:00 +0000 Subject: [PATCH 4/5] refine installer prompts and setup detection Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- install.sh | 2 +- src/setup.js | 6 ++++-- src/steps/frontend.js | 2 +- tests/install-script.test.js | 6 ++++++ tests/setup.test.js | 26 ++++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 6f18dc6..31e336a 100755 --- a/install.sh +++ b/install.sh @@ -56,7 +56,7 @@ else CLI_COMMAND="append" ;; *) - echo "Invalid selection: ${INSTALL_MODE}" >&2 + echo "Invalid selection: ${INSTALL_MODE}. Please enter 1 for init or 2 for append." >&2 exit 1 ;; esac diff --git a/src/setup.js b/src/setup.js index 8f6b30b..9b89023 100644 --- a/src/setup.js +++ b/src/setup.js @@ -59,6 +59,8 @@ export function detectCompletedSteps({ const suitupDir = join(home, ".config", "suitup"); const xdgDataHome = process.env.XDG_DATA_HOME || join(home, ".local", "share"); const zinitHome = join(xdgDataHome, "zinit", "zinit.git"); + const zshrc = readFileSafe(join(home, ".zshrc")); + const zshenv = readFileSafe(join(home, ".zshenv")); const vimrc = readFileSafe(join(home, ".vimrc")); if (hasCompletedBootstrap(platform, commandExistsFn)) { @@ -66,8 +68,8 @@ export function detectCompletedSteps({ } if ( - existsSync(join(home, ".zshrc")) && - existsSync(join(home, ".zshenv")) && + zshrc.includes("Generated by suitup") && + zshenv.includes("Generated by suitup") && existsSync(join(zshConfigDir, "core", "perf.zsh")) && existsSync(join(zshConfigDir, "core", "env.zsh")) && existsSync(join(zshConfigDir, "core", "paths.zsh")) && diff --git a/src/steps/frontend.js b/src/steps/frontend.js index c2fb89e..39a815f 100644 --- a/src/steps/frontend.js +++ b/src/steps/frontend.js @@ -51,7 +51,7 @@ export async function installFrontendTools() { } // Install Node via fnm - if (fnmReady || commandExists("fnm")) { + if (fnmReady) { p.log.step(`Installing Node.js v${ltsVersion} via fnm...`); try { await runStreamChecked(`fnm install ${ltsVersion} && fnm use ${ltsVersion} && fnm default ${ltsVersion}`); diff --git a/tests/install-script.test.js b/tests/install-script.test.js index 773c0ba..15e09a5 100644 --- a/tests/install-script.test.js +++ b/tests/install-script.test.js @@ -43,4 +43,10 @@ describe("install.sh", () => { expect(content).toContain('CLI_COMMAND="setup"'); expect(content).toContain('"${CLI_COMMAND}" "$@" < /dev/tty'); }); + + test("prints a helpful error for invalid installer mode selections", () => { + const content = readFileSync(INSTALL_SCRIPT, "utf-8"); + + expect(content).toContain('Please enter 1 for init or 2 for append.'); + }); }); diff --git a/tests/setup.test.js b/tests/setup.test.js index 28e8269..7d972f5 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -197,6 +197,32 @@ describe("Setup simulation in sandbox", () => { } }); + test("does not treat non-suitup shell files as completed zsh config", () => { + mkdirSync(join(sandbox, ".config", "zsh", "core"), { recursive: true }); + mkdirSync(join(sandbox, ".config", "zsh", "shared"), { recursive: true }); + mkdirSync(join(sandbox, ".config", "zsh", "local"), { recursive: true }); + + writeFileSync(join(sandbox, ".zshrc"), "# user managed\n", "utf-8"); + writeFileSync(join(sandbox, ".zshenv"), "# user managed\n", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "core", "perf.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "core", "env.zsh"), "", "utf-8"); + 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", "prompt.zsh"), "", "utf-8"); + writeFileSync(join(sandbox, ".config", "zsh", "local", "machine.zsh"), "", "utf-8"); + + const detected = detectCompletedSteps({ + home: sandbox, + platform: "darwin", + commandExistsFn(name) { + return ["zsh", "brew"].includes(name); + }, + }); + + expect(detected).not.toContain("zsh-config"); + }); + test("aliases file uses $HOME or ~ instead of hardcoded paths", () => { const content = readFileSync(join(CONFIGS_DIR, "aliases"), "utf-8"); From ec58484b72be6807448cfdc828bf9fd703b4aa5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:49:50 +0000 Subject: [PATCH 5/5] polish frontend error messages and tests Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- src/steps/frontend.js | 2 +- tests/frontend.test.js | 6 ++++-- tests/setup.test.js | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/steps/frontend.js b/src/steps/frontend.js index 39a815f..8340da3 100644 --- a/src/steps/frontend.js +++ b/src/steps/frontend.js @@ -4,7 +4,7 @@ import { brewInstall, commandExists, run, runStream } from "../utils/shell.js"; async function runStreamChecked(cmd) { const exitCode = await runStream(cmd); if (exitCode !== 0) { - throw new Error(`Command exited with status ${exitCode}`); + throw new Error(`Command failed with exit code ${exitCode}: ${cmd}`); } } diff --git a/tests/frontend.test.js b/tests/frontend.test.js index 027c917..f5a333a 100644 --- a/tests/frontend.test.js +++ b/tests/frontend.test.js @@ -17,6 +17,8 @@ import { installFrontendTools } from "../src/steps/frontend.js"; import { brewInstall, commandExists, run, runStream } from "../src/utils/shell.js"; import * as p from "@clack/prompts"; +const CURL_HTTP_ERROR_CODE = 22; + describe("frontend step", () => { beforeEach(() => { vi.clearAllMocks(); @@ -55,7 +57,7 @@ describe("frontend step", () => { if (name === "brew") return true; return true; }); - runStream.mockImplementationOnce(() => Promise.resolve(22)); + runStream.mockImplementationOnce(() => Promise.resolve(CURL_HTTP_ERROR_CODE)); brewInstall.mockReturnValue(true); await installFrontendTools(); @@ -71,7 +73,7 @@ describe("frontend step", () => { if (name === "fnm" || name === "brew") return false; return true; }); - runStream.mockImplementationOnce(() => Promise.resolve(22)); + runStream.mockImplementationOnce(() => Promise.resolve(CURL_HTTP_ERROR_CODE)); await installFrontendTools(); diff --git a/tests/setup.test.js b/tests/setup.test.js index 7d972f5..2a575ab 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -125,7 +125,7 @@ describe("Setup simulation in sandbox", () => { expect(getDefaultSteps("darwin")).toContain("apps"); }); - test("detects completed suitup-managed setup steps from sandbox files", () => { + 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 });