Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -47,14 +48,20 @@ 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:

```bash
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
Expand Down Expand Up @@ -104,7 +111,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

Expand Down
11 changes: 9 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Suitup 可以帮你初始化 Zsh 和 Homebrew,但更稳妥的路径仍然是
- 推荐:先安装 Homebrew,这样后续包管理和工具安装会更稳定
- 可选:如果你不想手动准备,也可以保留 `Bootstrap` 步骤,让 suitup 代为安装
- 如果初始化做到一半中断了,可以运行 `node src/cli.js append` 继续补齐缺失配置,或者切换 prompt 预设,而不必整体重写 `.zshrc`
- 如果 suitup 检测到本地已经存在 suitup 管理的配置,或者前端工具链已经装好,setup 现在会默认把这些已完成步骤反选掉,方便你只补剩余内容

### 安装并运行

Expand All @@ -47,14 +48,20 @@ 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` 命令

如果你想直接执行某个命令,也可以这样传参:

```bash
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
Expand Down Expand Up @@ -104,7 +111,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(追加)

Expand Down
18 changes: 12 additions & 6 deletions configs/core/paths.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 26 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}. Please enter 1 for init or 2 for append." >&2
exit 1
;;
esac
fi

echo "Downloading ${REPO_SLUG}@${SUITUP_REF}..."
curl --fail --show-error --silent --location "${ARCHIVE_URL}" --output "${ARCHIVE_PATH}"

Expand All @@ -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
91 changes: 90 additions & 1 deletion src/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -31,6 +33,87 @@ 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 zshrc = readFileSafe(join(home, ".zshrc"));
const zshenv = readFileSafe(join(home, ".zshenv"));
const vimrc = readFileSafe(join(home, ".vimrc"));

if (hasCompletedBootstrap(platform, commandExistsFn)) {
completed.add("bootstrap");
}

if (
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")) &&
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! ")));

Expand All @@ -42,6 +125,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,
Expand All @@ -57,7 +146,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)) {
Expand Down
48 changes: 38 additions & 10 deletions src/steps/frontend.js
Original file line number Diff line number Diff line change
@@ -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 failed with exit code ${exitCode}: ${cmd}`);
}
}

/**
* 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
Expand All @@ -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) {
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
Expand Down
4 changes: 3 additions & 1 deletion tests/configs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}:");
Expand Down
37 changes: 36 additions & 1 deletion tests/frontend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ 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";

const CURL_HTTP_ERROR_CODE = 22;

describe("frontend step", () => {
beforeEach(() => {
Expand Down Expand Up @@ -48,6 +51,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(CURL_HTTP_ERROR_CODE));
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(CURL_HTTP_ERROR_CODE));

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);

Expand Down
Loading
Loading