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
2 changes: 1 addition & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y hyperfine zsh
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
brew install atuin fnm fzf zoxide
brew install --no-ask atuin fnm fzf zoxide

mapfile -t insecure_dirs < <(
zsh -fc 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"; autoload -Uz compaudit; compaudit'
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Interactive step-by-step setup with selectable steps:
3. **Plugin Manager** — zinit (recommended) or skip for native zsh only
4. **Prompt Preset** — Powerlevel10k (recommended) or a basic zsh prompt
5. **CLI Tools** — bat, eza, fzf, fd, zoxide, atuin, ripgrep...
6. **GUI Apps** — iTerm2, Raycast, VS Code, fonts...
6. **GUI Apps** — optional Ghostty, Raycast, VS Code, fonts...
7. **Frontend Tools** — choose fnm, Node.js via fnm, pnpm, git-cz
8. **Shell Aliases** — git, eza, fzf shortcuts
9. **SSH Key** — generate GitHub SSH key
Expand Down Expand Up @@ -234,7 +234,7 @@ Prints the command list and available options.

### GUI apps

Selectable during setup: iTerm2, Raycast, VS Code, Itsycal, Monaspace font, and more.
Optionally selectable during setup: Ghostty, Raycast, VS Code, Itsycal, Monaspace font, and more. When Ghostty is selected, suitup asks whether to initialize it with a reusable preset. Existing `~/.config/ghostty` settings are backed up before the preset is applied.

### Frontend toolchain

Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ node src/cli.js
3. **Plugin Manager** — zinit(推荐)或跳过,仅保留原生 zsh
4. **Prompt Preset** — Powerlevel10k(推荐)或基础 zsh prompt
5. **CLI Tools** — bat、eza、fzf、fd、zoxide、atuin、ripgrep 等
6. **GUI Apps** — iTerm2、Raycast、VS Code、字体等
6. **GUI Apps** — 可选的 Ghostty、Raycast、VS Code、字体等
7. **Frontend Tools** — 可选 fnm、通过 fnm 安装的 Node.js、pnpm、git-cz
8. **Shell Aliases** — git、eza、fzf 等快捷命令
9. **SSH Key** — 生成 GitHub SSH 密钥
Expand Down Expand Up @@ -234,7 +234,7 @@ node src/cli.js --help

### GUI 应用

可在安装过程中选择:iTerm2、Raycast、VS Code、Itsycal、Monaspace 字体等。
安装过程中可按需选择 Ghostty、Raycast、VS Code、Itsycal、Monaspace 字体等。选择 Ghostty 后,suitup 会询问是否使用可复用的预设初始化;应用预设前会备份已有的 `~/.config/ghostty` 配置

### 前端工具链

Expand Down
6 changes: 6 additions & 0 deletions configs/ghostty/conf.d/appearance.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Theme, font, and visual settings
theme = Dracula+
font-size = 15
background-blur-radius = 20
background-opacity = 0.85
unfocused-split-opacity = 0.35
5 changes: 5 additions & 0 deletions configs/ghostty/conf.d/general.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# General behavior
shell-integration = zsh
copy-on-select = true
mouse-hide-while-typing = true
scrollback-limit = 50000
12 changes: 12 additions & 0 deletions configs/ghostty/conf.d/keybindings.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Split creation
keybind = "super+d=new_split:right"
keybind = "super+shift+d=new_split:down"

# Split navigation
keybind = "super+alt+up=goto_split:up"
keybind = "super+alt+down=goto_split:down"
keybind = "super+alt+left=goto_split:left"
keybind = "super+alt+right=goto_split:right"

# Close the current split
keybind = "super+w=close_surface"
5 changes: 5 additions & 0 deletions configs/ghostty/conf.d/window.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Window behavior and layout
confirm-close-surface = false
window-padding-x = 16
window-padding-y = 16
window-inherit-working-directory = false
5 changes: 5 additions & 0 deletions configs/ghostty/config
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Ghostty modular configuration
config-file = "conf.d/general.conf"
config-file = "conf.d/appearance.conf"
config-file = "conf.d/window.conf"
config-file = "conf.d/keybindings.conf"
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ install_with_manager() {

case "${manager}" in
brew)
brew install "$@"
brew install --no-ask "$@"
;;
Comment on lines 84 to 87

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

请移除 brew install 中的 --no-ask 选项,因为 Homebrew 并不支持该参数,会导致安装失败。如果需要非交互模式,Homebrew 官方推荐使用环境变量 NONINTERACTIVE=1(例如 NONINTERACTIVE=1 brew install ...)。

Suggested change
case "${manager}" in
brew)
brew install "$@"
brew install --no-ask "$@"
;;
brew)
brew install "$@"
;;

apt-get)
sudo apt-get update
Expand Down
5 changes: 3 additions & 2 deletions src/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export async function runSetup({ defaults = false } = {}) {
{ value: "bootstrap", label: "Bootstrap", hint: "Package manager + 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: "apps", label: "GUI Apps", hint: "Ghostty, Raycast, VS Code..." },
{ value: "frontend", label: "Frontend Tools", hint: "fnm, Node.js, pnpm, git-cz" },
{ value: "aliases", label: "Shell Aliases", hint: "git, eza, fzf shortcuts" },
{ value: "ssh", label: "SSH Key", hint: "generate GitHub SSH key" },
Expand Down Expand Up @@ -382,6 +382,7 @@ export async function runSetup({ defaults = false } = {}) {
} else {
const appChoice = await p.groupMultiselect({
message: "Select apps to install:",
required: false,
options: {
Recommended: APPS.recommended,
Optional: APPS.optional,
Expand Down Expand Up @@ -430,7 +431,7 @@ export async function runSetup({ defaults = false } = {}) {
}

if (steps.includes("apps")) {
await installApps(selectedApps);
await installApps(selectedApps, { configureGhostty: !defaults });
}

if (steps.includes("frontend")) {
Expand Down
54 changes: 41 additions & 13 deletions src/steps/apps.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import * as p from "@clack/prompts";
import { brewInstalled, brewInstall } from "../utils/shell.js";
import { cpSync, existsSync, rmSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { brewInstall } from "../utils/shell.js";

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

为了支持在已安装时进行升级检查,我们需要导入 brewInstalledbrewUpgrade

Suggested change
import { brewInstall } from "../utils/shell.js";
import { brewInstall, brewInstalled, brewUpgrade } from "../utils/shell.js";

import { CONFIGS_DIR } from "../constants.js";

/** All available GUI applications. */
export const APPS = {
recommended: [
{ value: "iterm2", label: "iTerm2", hint: "terminal emulator" },
{ value: "ghostty", label: "Ghostty", hint: "terminal emulator" },
{ value: "raycast", label: "Raycast", hint: "launcher & productivity" },
{ value: "visual-studio-code", label: "VS Code", hint: "code editor" },
],
Expand All @@ -19,23 +23,47 @@ export const APPS = {
],
};

export async function setupGhosttyConfig({ home = homedir() } = {}) {
const shouldInitialize = await p.confirm({
message: "Initialize Ghostty with the suitup preset?",
initialValue: true,
});
if (p.isCancel(shouldInitialize) || !shouldInitialize) {
p.log.info("Ghostty config left unchanged");
return false;
}

const destination = join(home, ".config", "ghostty");
if (existsSync(destination)) {
const backup = `${destination}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
cpSync(destination, backup, { recursive: true });
p.log.info(`Existing Ghostty config backed up to ${backup.replace(home, "~")}`);
rmSync(destination, { recursive: true, force: true });
}

cpSync(join(CONFIGS_DIR, "ghostty"), destination, { recursive: true });
p.log.success("Ghostty config initialized at ~/.config/ghostty/");
Comment on lines +36 to +45

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

如果用户的 ~/.config 目录在运行此脚本之前不存在(例如在全新的系统或新用户账户中),cpSync(..., destination, { recursive: true }) 会因为找不到父目录而抛出 ENOENT 错误。

建议在复制之前,先使用 mkdirSync 确保 ~/.config 目录存在。同时,请记得在文件顶部从 node:fs 导入 mkdirSync

Suggested change
const destination = join(home, ".config", "ghostty");
if (existsSync(destination)) {
const backup = `${destination}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
cpSync(destination, backup, { recursive: true });
p.log.info(`Existing Ghostty config backed up to ${backup.replace(home, "~")}`);
rmSync(destination, { recursive: true, force: true });
}
cpSync(join(CONFIGS_DIR, "ghostty"), destination, { recursive: true });
p.log.success("Ghostty config initialized at ~/.config/ghostty/");
const destination = join(home, ".config", "ghostty");
if (existsSync(destination)) {
const backup = `${destination}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
cpSync(destination, backup, { recursive: true });
p.log.info(`Existing Ghostty config backed up to ${backup.replace(home, "~")}`);
rmSync(destination, { recursive: true, force: true });
}
mkdirSync(join(home, ".config"), { recursive: true });
cpSync(join(CONFIGS_DIR, "ghostty"), destination, { recursive: true });

return true;
}

/**
* Install selected GUI apps via Homebrew Cask.
* @param {string[]} apps - list of cask names
* @param {{ configureGhostty?: boolean }} [opts]
*/
export async function installApps(apps) {
export async function installApps(apps, { configureGhostty = true } = {}) {
for (const app of apps) {
if (brewInstalled(app)) {
p.log.success(`${app} is already installed`);
const s = p.spinner();
s.start(`Installing or updating ${app}...`);
const ok = brewInstall(app, { cask: true });
if (ok) {
s.stop(`${app} is ready`);
} else {
const s = p.spinner();
s.start(`Installing ${app}...`);
const ok = brewInstall(app, { cask: true });
if (ok) {
s.stop(`${app} installed`);
} else {
s.stop(`Failed to install ${app}`);
}
s.stop(`Failed to install ${app}`);
}
}
Comment on lines +54 to 64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

直接无条件调用 brewInstall 会导致在应用已安装时报错(Homebrew 会因为已安装而返回非零状态码),从而错误地显示 Failed to install <app>

建议先通过 brewInstalled 检查是否已安装:如果已安装则调用 brewUpgrade 进行升级,未安装则调用 brewInstall 进行安装。这样既能保证“始终执行安装或升级检查”,又不会误报失败。

Suggested change
export async function installApps(apps, { configureGhostty = true } = {}) {
for (const app of apps) {
if (brewInstalled(app)) {
p.log.success(`${app} is already installed`);
const s = p.spinner();
s.start(`Installing or updating ${app}...`);
const ok = brewInstall(app, { cask: true });
if (ok) {
s.stop(`${app} is ready`);
} else {
const s = p.spinner();
s.start(`Installing ${app}...`);
const ok = brewInstall(app, { cask: true });
if (ok) {
s.stop(`${app} installed`);
} else {
s.stop(`Failed to install ${app}`);
}
s.stop(`Failed to install ${app}`);
}
}
export async function installApps(apps, { configureGhostty = true } = {}) {
for (const app of apps) {
const s = p.spinner();
s.start(`Installing or updating ${app}...`);
const installed = brewInstalled(app);
const ok = installed ? brewUpgrade(app, { cask: true }) : brewInstall(app, { cask: true });
if (ok) {
s.stop(`${app} is ready`);
} else {
s.stop(`Failed to install ${app}`);
}
}


if (configureGhostty && apps.includes("ghostty")) {
await setupGhosttyConfig();
}
}
2 changes: 1 addition & 1 deletion src/steps/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ async function installZshViaManager(manager) {

p.log.step("Installing Zsh...");
if (manager === "brew") {
await runStream("brew install zsh");
await runStream("brew install --no-ask zsh");
} else if (manager === "apt-get") {
Comment on lines 137 to 139

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

请移除 brew install 中的 --no-ask 选项,因为 Homebrew 并不支持该参数,在真实环境中会导致安装 Zsh 失败。

  if (manager === "brew") {
    await runStream("brew install zsh");
  }

await runStream("sudo apt-get update && sudo apt-get install -y zsh");
} else if (manager === "dnf") {
Expand Down
18 changes: 7 additions & 11 deletions src/steps/cli-tools.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as p from "@clack/prompts";
import { brewInstalled, brewInstall } from "../utils/shell.js";
import { brewInstall } from "../utils/shell.js";

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

导入 brewInstalledbrewUpgrade 以支持在工具已安装时进行升级检查。

Suggested change
import { brewInstall } from "../utils/shell.js";
import { brewInstall, brewInstalled, brewUpgrade } from "../utils/shell.js";


/** All available CLI tools with metadata. */
export const CLI_TOOLS = {
Expand Down Expand Up @@ -27,17 +27,13 @@ export const CLI_TOOLS = {
*/
export async function installCliTools(tools) {
for (const tool of tools) {
if (brewInstalled(tool)) {
p.log.success(`${tool} is already installed`);
const s = p.spinner();
s.start(`Installing or updating ${tool}...`);
const ok = brewInstall(tool);
if (ok) {
s.stop(`${tool} is ready`);
} else {
const s = p.spinner();
s.start(`Installing ${tool}...`);
const ok = brewInstall(tool);
if (ok) {
s.stop(`${tool} installed`);
} else {
s.stop(`Failed to install ${tool}`);
}
s.stop(`Failed to install ${tool}`);
}
}
}
Comment on lines 28 to 39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

apps.js 类似,直接无条件调用 brewInstall 会导致在工具已安装时报错。建议先通过 brewInstalled 检查是否已安装,如果已安装则调用 brewUpgrade 进行升级,未安装则调用 brewInstall 进行安装。

Suggested change
export async function installCliTools(tools) {
for (const tool of tools) {
if (brewInstalled(tool)) {
p.log.success(`${tool} is already installed`);
const s = p.spinner();
s.start(`Installing or updating ${tool}...`);
const ok = brewInstall(tool);
if (ok) {
s.stop(`${tool} is ready`);
} else {
const s = p.spinner();
s.start(`Installing ${tool}...`);
const ok = brewInstall(tool);
if (ok) {
s.stop(`${tool} installed`);
} else {
s.stop(`Failed to install ${tool}`);
}
s.stop(`Failed to install ${tool}`);
}
}
}
export async function installCliTools(tools) {
for (const tool of tools) {
const s = p.spinner();
s.start(`Installing or updating ${tool}...`);
const installed = brewInstalled(tool);
const ok = installed ? brewUpgrade(tool) : brewInstall(tool);
if (ok) {
s.stop(`${tool} is ready`);
} else {
s.stop(`Failed to install ${tool}`);
}
}
}

2 changes: 1 addition & 1 deletion src/utils/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function brewInstalled(name) {
* Install a Homebrew formula or cask. Returns true on success.
*/
export function brewInstall(name, { cask = false } = {}) {
const args = cask ? ["install", "--cask", name] : ["install", name];
const args = cask ? ["install", "--no-ask", "--cask", name] : ["install", "--no-ask", name];
try {
execSync(`brew ${args.join(" ")}`, { stdio: "inherit" });
return true;
Comment on lines 59 to 63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Homebrew 的 brew install 命令并没有 --no-ask 选项。传入未知的选项会导致 Homebrew 报错 Error: invalid option: --no-ask 并终止执行,从而导致所有工具和应用的安装失败。

另外,为了支持“始终执行安装或升级检查”的需求,建议在此处新增一个 brewUpgrade 函数,以便在软件已安装时调用 brew upgrade 进行更新。

export function brewInstall(name, { cask = false } = {}) {
  const args = cask ? ["install", "--cask", name] : ["install", name];
  try {
    execSync("brew " + args.join(" "), { stdio: "inherit" });
    return true;
  } catch {
    return false;
  }
}

/**
 * Upgrade a Homebrew formula or cask. Returns true on success.
 */
export function brewUpgrade(name, { cask = false } = {}) {
  const args = cask ? ["upgrade", "--cask", name] : ["upgrade", name];
  try {
    execSync("brew " + args.join(" "), { stdio: "inherit" });
    return true;
  } catch {
    return false;
  }
}

Expand Down
63 changes: 46 additions & 17 deletions tests/apps.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createSandbox } from "./helpers.js";

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

vi.mock("@clack/prompts", () => ({
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,
isCancel: vi.fn(() => false),
}));

vi.mock("../src/utils/shell.js", () => ({
Expand All @@ -13,38 +20,60 @@ vi.mock("../src/utils/shell.js", () => ({
runStream: vi.fn(() => Promise.resolve(0)),
}));

import { installApps } from "../src/steps/apps.js";
import { brewInstalled, brewInstall } from "../src/utils/shell.js";
import { APPS, installApps, setupGhosttyConfig } from "../src/steps/apps.js";
import { brewInstall } from "../src/utils/shell.js";

describe("apps step", () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfirm.mockResolvedValue(true);
});

test("skips apps that are already installed", async () => {
brewInstalled.mockReturnValue(true);

await installApps(["iterm2", "raycast"]);
test("installs or updates every selected app", async () => {
await installApps(["raycast", "visual-studio-code"]);

expect(brewInstall).not.toHaveBeenCalled();
expect(brewInstall).toHaveBeenCalledTimes(2);
expect(brewInstall).toHaveBeenCalledWith("raycast", { cask: true });
expect(brewInstall).toHaveBeenCalledWith("visual-studio-code", { cask: true });
});

test("installs apps that are not present", async () => {
brewInstalled.mockReturnValue(false);
test("continues when a selected app fails", async () => {
brewInstall.mockImplementation((name) => name !== "visual-studio-code");

await installApps(["iterm2", "visual-studio-code"]);
await installApps(["visual-studio-code", "raycast"], { configureGhostty: false });

expect(brewInstall).toHaveBeenCalledTimes(2);
expect(brewInstall).toHaveBeenCalledWith("iterm2", { cask: true });
expect(brewInstall).toHaveBeenCalledWith("visual-studio-code", { cask: true });
});

test("mixed: skips installed and installs missing", async () => {
brewInstalled.mockImplementation((name) => name === "iterm2");
test("offers Ghostty instead of iTerm2", () => {
expect(APPS.recommended.map((app) => app.value)).toContain("ghostty");
expect(APPS.recommended.map((app) => app.value)).not.toContain("iterm2");
});

await installApps(["iterm2", "raycast"]);
test("initializes the sanitized Ghostty preset after backing up existing config", async () => {
const sandbox = createSandbox();
const ghostty = join(sandbox.path, ".config", "ghostty");
mkdirSync(ghostty, { recursive: true });
writeFileSync(join(ghostty, "config"), "working-directory = /private/path\n");

expect(brewInstall).toHaveBeenCalledTimes(1);
expect(brewInstall).toHaveBeenCalledWith("raycast", { cask: true });
await setupGhosttyConfig({ home: sandbox.path });

const backups = readdirSync(join(sandbox.path, ".config")).filter((name) => name.startsWith("ghostty.backup-"));
expect(backups).toHaveLength(1);
expect(readFileSync(join(ghostty, "config"), "utf-8")).not.toContain("working-directory");
expect(existsSync(join(ghostty, "conf.d", "keybindings.conf"))).toBe(true);
sandbox.cleanup();
});

test("leaves Ghostty config unchanged when initialization is declined", async () => {
const sandbox = createSandbox();
const config = join(sandbox.path, ".config", "ghostty", "config");
mkdirSync(join(sandbox.path, ".config", "ghostty"), { recursive: true });
writeFileSync(config, "custom = true\n");
mockConfirm.mockResolvedValue(false);

expect(await setupGhosttyConfig({ home: sandbox.path })).toBe(false);
expect(readFileSync(config, "utf-8")).toBe("custom = true\n");
sandbox.cleanup();
});
});
2 changes: 1 addition & 1 deletion tests/bootstrap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("bootstrap step", () => {

await bootstrap({ platform: "darwin" });

expect(runStream).toHaveBeenCalledWith(expect.stringContaining("brew install zsh"));
expect(runStream).toHaveBeenCalledWith(expect.stringContaining("brew install --no-ask zsh"));
});
Comment on lines 85 to 88

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

在移除 src/steps/bootstrap.js 中的 --no-ask 选项后,此处的测试断言也需要同步更新,移除对 --no-ask 的匹配。

Suggested change
await bootstrap({ platform: "darwin" });
expect(runStream).toHaveBeenCalledWith(expect.stringContaining("brew install zsh"));
expect(runStream).toHaveBeenCalledWith(expect.stringContaining("brew install --no-ask zsh"));
});
await bootstrap({ platform: "darwin" });
expect(runStream).toHaveBeenCalledWith(expect.stringContaining("brew install zsh"));
});


test("supports Linux package manager selection", async () => {
Expand Down
23 changes: 5 additions & 18 deletions tests/cli-tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,39 +14,26 @@ vi.mock("../src/utils/shell.js", () => ({
}));

import { installCliTools } from "../src/steps/cli-tools.js";
import { brewInstalled, brewInstall } from "../src/utils/shell.js";
import { brewInstall } from "../src/utils/shell.js";

describe("cli-tools step", () => {
beforeEach(() => {
vi.clearAllMocks();
});

test("skips tools that are already installed", async () => {
brewInstalled.mockReturnValue(true);

await installCliTools(["bat", "eza", "fzf"]);

expect(brewInstall).not.toHaveBeenCalled();
});

test("installs tools that are not present", async () => {
brewInstalled.mockReturnValue(false);

test("installs or updates every selected tool", async () => {
await installCliTools(["bat", "eza"]);

expect(brewInstall).toHaveBeenCalledTimes(2);
expect(brewInstall).toHaveBeenCalledWith("bat");
expect(brewInstall).toHaveBeenCalledWith("eza");
});

test("mixed: skips installed and installs missing", async () => {
brewInstalled.mockImplementation((name) => name === "bat");
test("continues when a selected tool fails", async () => {
brewInstall.mockImplementation((name) => name !== "fzf");

await installCliTools(["bat", "fzf", "fd"]);
await installCliTools(["fzf", "fd"]);

expect(brewInstall).toHaveBeenCalledTimes(2);
expect(brewInstall).toHaveBeenCalledWith("fzf");
expect(brewInstall).toHaveBeenCalledWith("fd");
expect(brewInstall).not.toHaveBeenCalledWith("bat");
});
});
Loading