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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,21 @@ When you run suitup locally from the repo, use a zsh session. The curl installer
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash
```

The installer bootstraps missing `zsh` and Node.js/npm when possible, downloads a temporary copy of the repo, runs `npm ci`, then asks whether you want interactive `setup` or non-interactive `init` before launching inside `zsh`. On Linux, Node.js bootstrap now uses the official Node.js 20.x binary tarball so it is not tied to a specific distro repository.
The installer bootstraps missing `zsh` and Node.js/npm when possible, downloads a temporary copy of the repo, runs `npm ci`, then asks whether you want `setup` or guided `init` before launching the interactive flow inside `zsh`. Package installation commands themselves run without extra confirmation prompts. On Linux, Node.js bootstrap now uses the official Node.js 20.x binary tarball so it is not tied to a specific distro repository.

You can also pass a command directly to skip the prompt:

```bash
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- init
```

`init` is a non-interactive quick-start path that uses recommended defaults:
`init` is an interactive guided initialization path. It asks which steps, tools, and apps to install; the underlying package installation commands do not ask for confirmation again:

- 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
- choose CLI tools and frontend tooling
- choose GUI apps on macOS
- write shared aliases
- run `p10k configure` at the end to set up your prompt theme

Expand Down Expand Up @@ -93,7 +93,7 @@ node src/cli.js

| Command | Description |
|---------|-------------|
| `node src/cli.js init` | Non-interactive quick init with recommended defaults |
| `node src/cli.js init` | Interactive guided initialization |
| `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` |
Expand Down
10 changes: 5 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,21 @@ Suitup 可以帮你初始化 Zsh 和 Homebrew,但更稳妥的路径仍然是
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash
```

安装脚本会在必要时自动安装 `zsh` 和 Node.js/npm,临时下载仓库,执行 `npm ci`,然后询问你想要交互式 `setup` 还是非交互式 `init`,再在 `zsh` 中启动对应流程。在 Linux 上,Node.js 引导安装现在会使用官方 Node.js 20.x 二进制 tarball,不再依赖某个发行版的软件源。
安装脚本会在必要时自动安装 `zsh` 和 Node.js/npm,临时下载仓库,执行 `npm ci`,然后询问你想要运行 `setup` 还是引导式 `init`,再在 `zsh` 中启动交互流程。底层软件包安装命令本身不会重复要求确认。在 Linux 上,Node.js 引导安装现在会使用官方 Node.js 20.x 二进制 tarball,不再依赖某个发行版的软件源。

你也可以直接传入命令来跳过询问:

```bash
curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | bash -s -- init
```

`init` 是非交互式快速初始化路径,使用推荐默认值
`init` 是交互式引导初始化路径,会询问要执行的步骤、工具和应用;底层软件包安装命令不会再次要求确认

- 按需安装包管理器和 zsh
- 安装分层 zsh 配置
- 安装 zinit + Powerlevel10k 预设
- 安装推荐 CLI 工具和前端工具链
- 在 macOS 上安装推荐 GUI 应用
- 选择 CLI 工具和前端工具链
- 在 macOS 上选择 GUI 应用
- 写入共享 aliases
- 最后运行 `p10k configure` 完成提示符主题配置

Expand Down Expand Up @@ -93,7 +93,7 @@ node src/cli.js

| 命令 | 说明 |
|------|------|
| `node src/cli.js init` | 非交互式快速初始化,使用推荐默认值 |
| `node src/cli.js init` | 交互式引导初始化 |
| `node src/cli.js` | 完整交互式安装(默认) |
| `node src/cli.js setup` | 同上 |
| `node src/cli.js append` | 追加配置到已有 `.zshrc` |
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ elif [[ -r /dev/tty ]]; then
echo '' >&2
echo 'How would you like to run suitup?' >&2
echo ' 1) setup — interactive, choose each step yourself' >&2
echo ' 2) init — non-interactive, install everything with recommended defaults' >&2
echo ' 2) init — interactive guided initialization' >&2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the init command has been updated in src/cli.js to call runSetup() without any arguments, it now behaves identically to the setup command.

Presenting these as two distinct options here (with different descriptions) is misleading to the user, as both choices will launch the exact same interactive setup flow. Consider removing this prompt and defaulting to a single setup flow, or updating the descriptions to reflect that they are now identical.

echo '' >&2
read -r -p 'Enter 1 or 2 [default: 1]: ' _suitup_choice < /dev/tty
case "${_suitup_choice}" in
Expand Down
2 changes: 1 addition & 1 deletion src/cli-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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
init Interactive guided initialization
setup Full interactive environment setup (default)
append Append recommended configs to existing .zshrc
verify Verify installation and config integrity
Expand Down
2 changes: 1 addition & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function main(argv = process.argv) {
await runSetup();
break;
case "init":
await runSetup({ defaults: true });
await runSetup();
break;
Comment on lines 31 to 33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

With this change, the init command now calls runSetup() with no arguments, making it identical in behavior to the setup command. Both commands will run the full interactive setup flow.

This redundancy makes the distinction between init and setup in install.sh, README.md, and the help text confusing for users, as they are presented as two different options (e.g., 'interactive, choose each step yourself' vs 'interactive guided initialization') but result in the exact same experience.

If init and setup are intended to be identical, consider simplifying the CLI and installer by removing the redundant option or clearly documenting init as an alias. If they are intended to be different, init should pass specific options to runSetup to differentiate its behavior.

case "append":
await runAppend();
Expand Down
4 changes: 3 additions & 1 deletion src/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ export async function runSetup({ defaults = false } = {}) {

if (steps.includes("zsh-config")) {
await setupZshConfig({ promptTheme });
await writeZshrc(pluginManager);
if (await writeZshrc(pluginManager) === "cancelled") {
return;
}
await writeZshenv();
}

Expand Down
11 changes: 9 additions & 2 deletions src/steps/zsh-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,16 +154,23 @@ export async function writeZshrc(pluginManager = "zinit", { home } = {}) {
initialValue: false,
});

if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
if (p.isCancel(shouldOverwrite)) {
p.cancel("Setup cancelled.");
return "cancelled";
}

if (!shouldOverwrite) {
p.log.warn(".zshrc left unchanged. Use 'append' mode to add configs to your existing .zshrc.");
return;
return "skipped";
}

writeFile(zshrc, template);
p.log.success(".zshrc written (backup saved under ~/.config/zsh/backups/)");
return "written";
} else {
writeFile(zshrc, template);
p.log.success(".zshrc created");
return "written";
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/utils/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ export function brewInstall(name, { cask = false } = {}) {
try {
execSync(`brew ${args.join(" ")}`, { stdio: "inherit" });
return true;
} catch {
} catch (error) {
if (error.signal === "SIGINT" || error.status === 130) {
throw new ShellCommandError(`brew ${args.join(" ")}`, {
code: error.status,
signal: error.signal,
});
}
return false;
}
}
Expand Down
4 changes: 2 additions & 2 deletions tests/cli-main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ describe("cli main", () => {
expect(runSetup).toHaveBeenCalledTimes(1);
});

test("routes init to non-interactive setup defaults", async () => {
test("routes init to interactive setup", async () => {
const { main } = await import("../src/cli.js");

await main(["node", "src/cli.js", "init"]);

expect(requireZshShell).toHaveBeenCalledWith();
expect(runSetup).toHaveBeenCalledWith({ defaults: true });
expect(runSetup).toHaveBeenCalledWith();
});

test("prints help without invoking async commands", async () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/install-script.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("install.sh", () => {

expect(content).toContain("How would you like to run suitup?");
expect(content).toContain("interactive, choose each step yourself");
expect(content).toContain("non-interactive, install everything with recommended defaults");
expect(content).toContain("interactive guided initialization");
});

test("passes init directly to the CLI and validates supported commands", () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/perf.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ _run() {
local label="$1"
local script="$2"
local out rc
out=$(zsh -c "$script" 2>&1)
out=$(zsh -c "unset SUITUP_STARTUP_REPORT_THRESHOLD_MS SUITUP_SHOW_COMPLETION_CACHE_HINT; $script" 2>&1)
rc=$?
if (( rc == 0 )); then
_ok "$label"
Expand Down
11 changes: 11 additions & 0 deletions tests/setup-flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { runSetup } from "../src/setup.js";
describe("setup flow ordering", () => {
beforeEach(() => {
vi.clearAllMocks();
writeZshrc.mockResolvedValue(undefined);
mockMultiSelect.mockResolvedValue(["zsh-config", "bootstrap", "cli-tools"]);
mockSelect.mockResolvedValue("basic");
mockGroupMultiselect.mockResolvedValue(["bat"]);
Expand All @@ -134,6 +135,16 @@ describe("setup flow ordering", () => {
);
});

test("stops setup when .zshrc overwrite confirmation is cancelled", async () => {
mockMultiSelect.mockResolvedValue(["zsh-config", "bootstrap"]);
writeZshrc.mockResolvedValue("cancelled");

await runSetup();

expect(writeZshenv).not.toHaveBeenCalled();
expect(bootstrap).not.toHaveBeenCalled();
});

test("continues with later selections when bootstrap does not require a rerun", async () => {
bootstrap.mockResolvedValue({ manager: "apt-get", shouldRerun: false });

Expand Down
8 changes: 8 additions & 0 deletions tests/shell.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,12 @@ describe("shell utilities", () => {
expect(brewInstall("ghostty", { cask: true })).toBe(true);
expect(execSync).toHaveBeenCalledWith("brew install --no-ask --cask ghostty", { stdio: "inherit" });
});

test("propagates Ctrl-C from Homebrew installs", () => {
execSync.mockImplementation(() => {
throw Object.assign(new Error("interrupted"), { signal: "SIGINT", status: null });
});

expect(() => brewInstall("jq")).toThrow(expect.objectContaining({ interrupted: true }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Vitest (and Jest), passing an asymmetric matcher like expect.objectContaining directly to toThrow (or toThrowError) is not natively supported and may not assert the properties of the thrown error as expected. It often defaults to checking if any error is thrown, which can lead to silent test passes even if the error properties are incorrect.

To reliably assert custom properties on a thrown error, you should catch the error and assert its properties directly.

    let thrownError;
    try {
      brewInstall("jq");
    } catch (error) {
      thrownError = error;
    }
    expect(thrownError).toBeDefined();
    expect(thrownError.interrupted).toBe(true);

});
});
16 changes: 14 additions & 2 deletions tests/zsh-config-steps.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSy
import { join } from "node:path";
import { createSandbox } from "./helpers.js";

const { mockConfirm } = vi.hoisted(() => ({
const { mockConfirm, mockIsCancel } = vi.hoisted(() => ({
mockConfirm: vi.fn(),
mockIsCancel: vi.fn(() => false),
}));

vi.mock("@clack/prompts", () => ({
cancel: vi.fn(),
log: { success: vi.fn(), step: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
confirm: mockConfirm,
text: vi.fn(),
isCancel: vi.fn(() => false),
isCancel: mockIsCancel,
}));

import { backupShellRcFiles, setupZshConfig, writeZshrc, writeZshenv } from "../src/steps/zsh-config.js";
Expand All @@ -26,6 +28,7 @@ describe("zsh-config step", () => {

beforeEach(() => {
vi.clearAllMocks();
mockIsCancel.mockReturnValue(false);
sandbox = createSandbox();
});

Expand Down Expand Up @@ -194,6 +197,15 @@ describe("zsh-config step", () => {
expect(content).toContain("FOO=bar");
});

test("writeZshrc reports cancellation instead of treating Ctrl-C as decline", async () => {
writeFileSync(join(sandbox.path, ".zshrc"), "# custom\n", "utf-8");
mockIsCancel.mockReturnValue(true);

expect(await writeZshrc("zinit", { home: sandbox.path })).toBe("cancelled");
expect(readFileSync(join(sandbox.path, ".zshrc"), "utf-8")).toBe("# custom\n");
expect(p.cancel).toHaveBeenCalledWith("Setup cancelled.");
});

test("writeZshrc creates backup when user confirms overwrite", async () => {
writeFileSync(join(sandbox.path, ".zshrc"), "# my custom zshrc\nexport FOO=bar", "utf-8");
mockConfirm.mockResolvedValue(true);
Expand Down