Skip to content

Commit 473992a

Browse files
CopilotChangeHow
andauthored
Restore fnm availability after suitup rewrites shell config (#23)
- [x] Re-check the reviewer comment and confirm the remaining requested changes - [x] Re-inspect the setup, frontend installer, and install.sh flows before editing - [x] Fix fnm installation success/failure handling and add Homebrew fallback when available - [x] Detect existing suitup-managed state and avoid preselecting already-completed setup steps - [x] Add an init vs append choice to install.sh and cover it with focused tests - [x] Run targeted tests for the touched areas, review the final diff, and update the PR title/comment reply <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com>
1 parent 48ff534 commit 473992a

10 files changed

Lines changed: 348 additions & 25 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Suitup can bootstrap Zsh and Homebrew for you, but the most reliable path is to
3636
- Recommended: install Homebrew first so later package/tool steps run in a known-good environment
3737
- Optional: if you skip either one, keep the `Bootstrap` step selected and let suitup set them up for you
3838
- 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`
39+
- When suitup detects existing suitup-managed config or already-installed frontend prerequisites, setup now deselects those completed steps by default so reruns stay focused
3940

4041
### Install and run
4142

@@ -47,14 +48,20 @@ Suitup now assumes zsh is already installed and that you are running the command
4748
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash
4849
```
4950

50-
The installer downloads a temporary copy of the repo, runs `npm ci`, and then launches `node src/cli.js` inside `zsh`.
51+
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`.
5152

5253
You can also pass a specific command to the installer:
5354

5455
```bash
5556
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- clean
5657
```
5758

59+
If you want append mode directly without the prompt:
60+
61+
```bash
62+
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- append
63+
```
64+
5865
### Clone locally
5966

6067
```bash
@@ -104,7 +111,7 @@ Bootstrap details:
104111
- Linux: choose `apt-get`, `dnf`, `yum`, `brew`, or skip
105112
- If Homebrew is already installed in a non-default location, suitup now tries common shellenv paths automatically during Zsh startup
106113
- Suitup now also writes a minimal `~/.zshenv` so non-interactive shells can still load shared env vars and PATH setup
107-
- 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
114+
- 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
108115

109116
### Append
110117

README.zh-CN.md

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

4041
### 安装并运行
4142

@@ -47,14 +48,20 @@ suitup 现在默认你已经安装好 zsh,并且当前就在 zsh 会话里运
4748
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash
4849
```
4950

50-
这个安装脚本会临时下载仓库、执行 `npm ci`然后在 `zsh` 中启动 `node src/cli.js`
51+
这个安装脚本会先询问你要走 `init`(完整 setup)还是 `append`(给现有 `.zshrc` 做增量补充),然后临时下载仓库、执行 `npm ci`最后在 `zsh` 中启动对应的 `node src/cli.js` 命令
5152

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

5455
```bash
5556
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- clean
5657
```
5758

59+
如果你想跳过提示、直接进入 append 模式,也可以这样运行:
60+
61+
```bash
62+
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- append
63+
```
64+
5865
### 本地 clone 运行
5966

6067
```bash
@@ -104,7 +111,7 @@ Bootstrap 细节:
104111
- Linux:可选 `apt-get``dnf``yum``brew`,或直接跳过
105112
- 如果 Homebrew 已经安装在非默认位置,suitup 现在会在 Zsh 启动时自动尝试常见 `shellenv` 路径
106113
- suitup 现在也会生成一个精简的 `~/.zshenv`,保证非交互式 shell 也能加载共享环境变量和 PATH
107-
- 当 fnm 安装 Node.js 后,suitup 会把该版本设置为 fnm 默认版本,确保交互式/非交互式 shell 下的 `node``npm` 和全局 CLI 都优先指向 fnm 管理的路径
114+
- 当 fnm 安装 Node.js 后,suitup 会把 `fnm` 自身和该默认 Node 版本一起放进 PATH,确保交互式/非交互式 shell 下的 `fnm``node``npm` 和全局 CLI 都优先指向 fnm 管理的路径
108115

109116
### Append(追加)
110117

configs/core/paths.zsh

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,20 @@ do
1616
done
1717
unset _suitup_brew_bin
1818

19-
# fnm (Fast Node Manager) — expose the default Node installation to all
20-
# shells so that globally-installed CLIs (pnpm, git-cz …) work in
21-
# non-interactive contexts such as scripts, editors, agents, and git hooks.
22-
# Interactive shells get the full fnm env from shared/tools.zsh instead.
23-
_suitup_fnm_default_bin="${FNM_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fnm}/aliases/default/bin"
19+
# fnm (Fast Node Manager) — keep the fnm binary itself on PATH after suitup
20+
# rewrites ~/.zshrc, then expose the default Node installation to all shells
21+
# so globally-installed CLIs (pnpm, git-cz …) work in non-interactive
22+
# contexts such as scripts, editors, agents, and git hooks. Interactive
23+
# shells get the full fnm env from shared/tools.zsh instead.
24+
_suitup_fnm_dir="${FNM_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fnm}"
25+
if [[ -d "$_suitup_fnm_dir" && ":${PATH}:" != *":${_suitup_fnm_dir}:"* ]]; then
26+
export PATH="${_suitup_fnm_dir}:${PATH}"
27+
fi
28+
29+
_suitup_fnm_default_bin="${_suitup_fnm_dir}/aliases/default/bin"
2430
if [[ -d "$_suitup_fnm_default_bin" && ":${PATH}:" != *":${_suitup_fnm_default_bin}:"* ]]; then
2531
export PATH="${_suitup_fnm_default_bin}:${PATH}"
2632
fi
27-
unset _suitup_fnm_default_bin
33+
unset _suitup_fnm_dir _suitup_fnm_default_bin
2834

2935
# Keep this file for user PATH overrides if needed.

install.sh

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ if (major < 18) {
3737
}
3838
'
3939

40+
CLI_COMMAND="${1:-}"
41+
if [[ -n "${CLI_COMMAND}" ]]; then
42+
shift
43+
if [[ "${CLI_COMMAND}" == "init" ]]; then
44+
CLI_COMMAND="setup"
45+
fi
46+
else
47+
echo "Choose install mode:"
48+
echo " 1) init - full interactive setup"
49+
echo " 2) append - incremental config updates for an existing ~/.zshrc"
50+
read -r -p "Select [1-2] (default 1): " INSTALL_MODE < /dev/tty
51+
case "${INSTALL_MODE:-1}" in
52+
1)
53+
CLI_COMMAND="setup"
54+
;;
55+
2)
56+
CLI_COMMAND="append"
57+
;;
58+
*)
59+
echo "Invalid selection: ${INSTALL_MODE}. Please enter 1 for init or 2 for append." >&2
60+
exit 1
61+
;;
62+
esac
63+
fi
64+
4065
echo "Downloading ${REPO_SLUG}@${SUITUP_REF}..."
4166
curl --fail --show-error --silent --location "${ARCHIVE_URL}" --output "${ARCHIVE_PATH}"
4267

@@ -49,4 +74,4 @@ cd "${WORK_DIR}/repo"
4974
npm ci --no-fund --no-audit
5075

5176
echo "Launching suitup inside zsh..."
52-
zsh -lc 'cd "$1" && shift && node src/cli.js "$@"' -- "${WORK_DIR}/repo" "$@" < /dev/tty
77+
zsh -lc 'cd "$1" && shift && node src/cli.js "$@"' -- "${WORK_DIR}/repo" "${CLI_COMMAND}" "$@" < /dev/tty

src/setup.js

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { setupVim } from "./steps/vim.js";
1313
import { setupAliases } from "./steps/aliases.js";
1414
import { cleanDock } from "./steps/dock.js";
1515
import { setupZshConfig, writeZshrc, writeZshenv } from "./steps/zsh-config.js";
16+
import { readFileSafe } from "./utils/fs.js";
17+
import { commandExists } from "./utils/shell.js";
1618
import { isZshShell } from "./utils/shell-context.js";
1719
export { isZshShell } from "./utils/shell-context.js";
1820

@@ -31,6 +33,87 @@ export function getDefaultSteps(platform = process.platform) {
3133
];
3234
}
3335

36+
function hasCompletedBootstrap(platform, commandExistsFn) {
37+
if (!commandExistsFn("zsh")) {
38+
return false;
39+
}
40+
41+
if (platform === "darwin") {
42+
return commandExistsFn("brew");
43+
}
44+
45+
if (platform === "linux") {
46+
return ["apt-get", "dnf", "yum", "brew"].some((manager) => commandExistsFn(manager));
47+
}
48+
49+
return false;
50+
}
51+
52+
export function detectCompletedSteps({
53+
home = homedir(),
54+
platform = process.platform,
55+
commandExistsFn = commandExists,
56+
} = {}) {
57+
const completed = new Set();
58+
const zshConfigDir = join(home, ".config", "zsh");
59+
const suitupDir = join(home, ".config", "suitup");
60+
const xdgDataHome = process.env.XDG_DATA_HOME || join(home, ".local", "share");
61+
const zinitHome = join(xdgDataHome, "zinit", "zinit.git");
62+
const zshrc = readFileSafe(join(home, ".zshrc"));
63+
const zshenv = readFileSafe(join(home, ".zshenv"));
64+
const vimrc = readFileSafe(join(home, ".vimrc"));
65+
66+
if (hasCompletedBootstrap(platform, commandExistsFn)) {
67+
completed.add("bootstrap");
68+
}
69+
70+
if (
71+
zshrc.includes("Generated by suitup") &&
72+
zshenv.includes("Generated by suitup") &&
73+
existsSync(join(zshConfigDir, "core", "perf.zsh")) &&
74+
existsSync(join(zshConfigDir, "core", "env.zsh")) &&
75+
existsSync(join(zshConfigDir, "core", "paths.zsh")) &&
76+
existsSync(join(zshConfigDir, "core", "options.zsh")) &&
77+
existsSync(join(zshConfigDir, "shared", "tools.zsh")) &&
78+
existsSync(join(zshConfigDir, "shared", "prompt.zsh")) &&
79+
existsSync(join(zshConfigDir, "local", "machine.zsh"))
80+
) {
81+
completed.add("zsh-config");
82+
}
83+
84+
if (existsSync(join(suitupDir, "zinit-plugins")) || existsSync(zinitHome)) {
85+
completed.add("plugins");
86+
}
87+
88+
if (existsSync(join(suitupDir, "aliases"))) {
89+
completed.add("aliases");
90+
}
91+
92+
if (
93+
commandExistsFn("fnm") &&
94+
commandExistsFn("node") &&
95+
commandExistsFn("pnpm") &&
96+
commandExistsFn("git-cz")
97+
) {
98+
completed.add("frontend");
99+
}
100+
101+
if (existsSync(join(home, ".ssh", "github_rsa"))) {
102+
completed.add("ssh");
103+
}
104+
105+
if (existsSync(join(suitupDir, "config.vim")) && vimrc.includes("config.vim")) {
106+
completed.add("vim");
107+
}
108+
109+
return [...completed];
110+
}
111+
112+
export function getInitialStepValues(opts = {}) {
113+
const completed = new Set(detectCompletedSteps(opts));
114+
return getDefaultSteps(opts.platform).filter((step) => !completed.has(step));
115+
}
116+
34117
export async function runSetup() {
35118
p.intro(pc.bgCyan(pc.black(" Suit up! ")));
36119

@@ -42,6 +125,12 @@ export async function runSetup() {
42125
}
43126

44127
// --- Step 1: Select setup steps ---
128+
const completedSteps = detectCompletedSteps();
129+
const initialValues = getInitialStepValues();
130+
if (completedSteps.length > 0) {
131+
p.log.info(`Deselected already configured steps: ${completedSteps.join(", ")}`);
132+
}
133+
45134
const steps = await p.multiselect({
46135
message: "Select setup steps:",
47136
required: true,
@@ -57,7 +146,7 @@ export async function runSetup() {
57146
{ value: "vim", label: "Vim Config", hint: "basic vim setup" },
58147
{ value: "dock", label: "Dock Cleanup", hint: "clean macOS Dock" },
59148
],
60-
initialValues: getDefaultSteps(),
149+
initialValues,
61150
});
62151

63152
if (p.isCancel(steps)) {

src/steps/frontend.js

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,41 @@
11
import * as p from "@clack/prompts";
2-
import { commandExists, run, runStream } from "../utils/shell.js";
2+
import { brewInstall, commandExists, run, runStream } from "../utils/shell.js";
3+
4+
async function runStreamChecked(cmd) {
5+
const exitCode = await runStream(cmd);
6+
if (exitCode !== 0) {
7+
throw new Error(`Command failed with exit code ${exitCode}: ${cmd}`);
8+
}
9+
}
310

411
/**
512
* Install fnm (Fast Node Manager) and set up Node.js + pnpm.
613
*/
714
export async function installFrontendTools() {
15+
let fnmReady = commandExists("fnm");
16+
817
// fnm
9-
if (commandExists("fnm")) {
18+
if (fnmReady) {
1019
p.log.success("fnm is already installed");
1120
} else {
1221
p.log.step("Installing fnm...");
13-
await runStream("curl -fsSL https://fnm.vercel.app/install | bash");
14-
p.log.success("fnm installed");
22+
try {
23+
await runStreamChecked("curl -fsSL https://fnm.vercel.app/install | bash");
24+
p.log.success("fnm installed");
25+
fnmReady = true;
26+
} catch {
27+
if (commandExists("brew")) {
28+
p.log.warn("Could not install fnm via curl, trying Homebrew...");
29+
if (brewInstall("fnm")) {
30+
p.log.success("fnm installed via Homebrew");
31+
fnmReady = true;
32+
} else {
33+
p.log.warn("Could not install fnm via curl or Homebrew");
34+
}
35+
} else {
36+
p.log.warn("Could not install fnm via curl, and Homebrew is not available");
37+
}
38+
}
1539
}
1640

1741
// Fetch latest LTS version
@@ -27,12 +51,16 @@ export async function installFrontendTools() {
2751
}
2852

2953
// Install Node via fnm
30-
p.log.step(`Installing Node.js v${ltsVersion} via fnm...`);
31-
try {
32-
await runStream(`fnm install ${ltsVersion} && fnm use ${ltsVersion} && fnm default ${ltsVersion}`);
33-
p.log.success(`Node.js v${ltsVersion} installed`);
34-
} catch {
35-
p.log.warn("Could not install Node.js — fnm may need a shell restart first");
54+
if (fnmReady) {
55+
p.log.step(`Installing Node.js v${ltsVersion} via fnm...`);
56+
try {
57+
await runStreamChecked(`fnm install ${ltsVersion} && fnm use ${ltsVersion} && fnm default ${ltsVersion}`);
58+
p.log.success(`Node.js v${ltsVersion} installed`);
59+
} catch {
60+
p.log.warn("Could not install Node.js — fnm may need a shell restart first");
61+
}
62+
} else {
63+
p.log.warn("Skipping Node.js install because fnm is unavailable");
3664
}
3765

3866
// pnpm

tests/configs.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,11 @@ describe("Static config templates", () => {
122122
expect(content).toContain("shellenv zsh");
123123
});
124124

125-
test("core/paths.zsh exposes fnm default node for non-interactive shells", () => {
125+
test("core/paths.zsh keeps fnm and its default node on PATH for non-interactive shells", () => {
126126
const content = readFileSync(join(CONFIGS_DIR, "core", "paths.zsh"), "utf-8");
127127
expect(content).toContain("fnm");
128+
expect(content).toContain("_suitup_fnm_dir");
129+
expect(content).toContain(".local/share}/fnm");
128130
expect(content).toContain("aliases/default/bin");
129131
// Should check for PATH deduplication
130132
expect(content).toContain(":${PATH}:");

tests/frontend.test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ vi.mock("../src/utils/shell.js", () => ({
1414
}));
1515

1616
import { installFrontendTools } from "../src/steps/frontend.js";
17-
import { commandExists, run, runStream } from "../src/utils/shell.js";
17+
import { brewInstall, commandExists, run, runStream } from "../src/utils/shell.js";
18+
import * as p from "@clack/prompts";
19+
20+
const CURL_HTTP_ERROR_CODE = 22;
1821

1922
describe("frontend step", () => {
2023
beforeEach(() => {
@@ -48,6 +51,38 @@ describe("frontend step", () => {
4851
expect(calls.some((c) => c.includes("fnm.vercel.app"))).toBe(true);
4952
});
5053

54+
test("falls back to Homebrew when fnm curl install fails", async () => {
55+
commandExists.mockImplementation((name) => {
56+
if (name === "fnm") return false;
57+
if (name === "brew") return true;
58+
return true;
59+
});
60+
runStream.mockImplementationOnce(() => Promise.resolve(CURL_HTTP_ERROR_CODE));
61+
brewInstall.mockReturnValue(true);
62+
63+
await installFrontendTools();
64+
65+
expect(brewInstall).toHaveBeenCalledWith("fnm");
66+
expect(p.log.success).not.toHaveBeenCalledWith("fnm installed");
67+
expect(p.log.warn).toHaveBeenCalledWith("Could not install fnm via curl, trying Homebrew...");
68+
expect(p.log.success).toHaveBeenCalledWith("fnm installed via Homebrew");
69+
});
70+
71+
test("warns when fnm curl install fails and Homebrew is unavailable", async () => {
72+
commandExists.mockImplementation((name) => {
73+
if (name === "fnm" || name === "brew") return false;
74+
return true;
75+
});
76+
runStream.mockImplementationOnce(() => Promise.resolve(CURL_HTTP_ERROR_CODE));
77+
78+
await installFrontendTools();
79+
80+
expect(brewInstall).not.toHaveBeenCalled();
81+
expect(p.log.success).not.toHaveBeenCalledWith("fnm installed");
82+
expect(p.log.warn).toHaveBeenCalledWith("Could not install fnm via curl, and Homebrew is not available");
83+
expect(p.log.warn).toHaveBeenCalledWith("Skipping Node.js install because fnm is unavailable");
84+
});
85+
5186
test("sets fnm default after installing node", async () => {
5287
commandExists.mockReturnValue(true);
5388

0 commit comments

Comments
 (0)