From 4fa88e9b21aff0e6afdf48e94c69348f7acc86d3 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Sat, 11 Jul 2026 20:30:24 +0800 Subject: [PATCH 1/2] feat(apps): add optional Ghostty setup --- README.md | 4 +- README.zh-CN.md | 4 +- configs/ghostty/conf.d/appearance.conf | 6 +++ configs/ghostty/conf.d/general.conf | 5 ++ configs/ghostty/conf.d/keybindings.conf | 12 +++++ configs/ghostty/conf.d/window.conf | 5 ++ configs/ghostty/config | 5 ++ src/setup.js | 5 +- src/steps/apps.js | 54 ++++++++++++++++----- tests/apps.test.js | 63 ++++++++++++++++++------- tests/setup-flow.test.js | 23 ++++++++- tests/setup.test.js | 2 +- 12 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 configs/ghostty/conf.d/appearance.conf create mode 100644 configs/ghostty/conf.d/general.conf create mode 100644 configs/ghostty/conf.d/keybindings.conf create mode 100644 configs/ghostty/conf.d/window.conf create mode 100644 configs/ghostty/config diff --git a/README.md b/README.md index 02b33fe..2155f92 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index e698e44..3b5abae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 密钥 @@ -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` 配置。 ### 前端工具链 diff --git a/configs/ghostty/conf.d/appearance.conf b/configs/ghostty/conf.d/appearance.conf new file mode 100644 index 0000000..8254c1a --- /dev/null +++ b/configs/ghostty/conf.d/appearance.conf @@ -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 diff --git a/configs/ghostty/conf.d/general.conf b/configs/ghostty/conf.d/general.conf new file mode 100644 index 0000000..7d3f4d3 --- /dev/null +++ b/configs/ghostty/conf.d/general.conf @@ -0,0 +1,5 @@ +# General behavior +shell-integration = zsh +copy-on-select = true +mouse-hide-while-typing = true +scrollback-limit = 50000 diff --git a/configs/ghostty/conf.d/keybindings.conf b/configs/ghostty/conf.d/keybindings.conf new file mode 100644 index 0000000..7d602ca --- /dev/null +++ b/configs/ghostty/conf.d/keybindings.conf @@ -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" diff --git a/configs/ghostty/conf.d/window.conf b/configs/ghostty/conf.d/window.conf new file mode 100644 index 0000000..bc37e39 --- /dev/null +++ b/configs/ghostty/conf.d/window.conf @@ -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 diff --git a/configs/ghostty/config b/configs/ghostty/config new file mode 100644 index 0000000..f72da25 --- /dev/null +++ b/configs/ghostty/config @@ -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" diff --git a/src/setup.js b/src/setup.js index 3e2b702..164ec62 100644 --- a/src/setup.js +++ b/src/setup.js @@ -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" }, @@ -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, @@ -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")) { diff --git a/src/steps/apps.js b/src/steps/apps.js index 45ccaee..8cfe49b 100644 --- a/src/steps/apps.js +++ b/src/steps/apps.js @@ -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"; +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" }, ], @@ -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/"); + 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}`); } } + + if (configureGhostty && apps.includes("ghostty")) { + await setupGhosttyConfig(); + } } diff --git a/tests/apps.test.js b/tests/apps.test.js index e877bf5..05c6db7 100644 --- a/tests/apps.test.js +++ b/tests/apps.test.js @@ -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", () => ({ @@ -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(); }); }); diff --git a/tests/setup-flow.test.js b/tests/setup-flow.test.js index a824fbc..1e5d9b0 100644 --- a/tests/setup-flow.test.js +++ b/tests/setup-flow.test.js @@ -24,6 +24,7 @@ const writeZshrc = vi.hoisted(() => vi.fn()); const writeZshenv = vi.hoisted(() => vi.fn()); const installCliTools = vi.hoisted(() => vi.fn()); const installFrontendTools = vi.hoisted(() => vi.fn()); +const installApps = vi.hoisted(() => vi.fn()); vi.mock("@clack/prompts", () => ({ note: mockNote, @@ -71,11 +72,11 @@ vi.mock("../src/steps/cli-tools.js", () => ({ vi.mock("../src/steps/apps.js", () => ({ APPS: { - recommended: [], + recommended: [{ value: "ghostty", label: "Ghostty" }], optional: [], fonts: [], }, - installApps: vi.fn(), + installApps, })); vi.mock("../src/steps/frontend.js", () => ({ @@ -170,4 +171,22 @@ describe("setup flow ordering", () => { }); expect(installFrontendTools).toHaveBeenCalledWith(["fnm", "pnpm"]); }); + + test("allows skipping all GUI apps", async () => { + mockMultiSelect.mockResolvedValue(["apps"]); + mockGroupMultiselect.mockResolvedValue([]); + + await runSetup(); + + expect(mockGroupMultiselect).toHaveBeenCalledWith({ + message: "Select apps to install:", + required: false, + options: { + Recommended: [{ value: "ghostty", label: "Ghostty" }], + Optional: [], + Fonts: [], + }, + }); + expect(installApps).toHaveBeenCalledWith([], { configureGhostty: true }); + }); }); diff --git a/tests/setup.test.js b/tests/setup.test.js index fc9fc13..43e1b70 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -175,7 +175,7 @@ describe("Setup simulation in sandbox", () => { test("quick init uses recommended GUI apps only", () => { expect(getRecommendedAppValues()).toEqual([ - "iterm2", + "ghostty", "raycast", "visual-studio-code", ]); From 119878bd03fad3dd708d39863b04231ebc73ef7a Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Sat, 11 Jul 2026 20:30:45 +0800 Subject: [PATCH 2/2] fix(brew): update selected packages non-interactively --- .github/workflows/benchmark.yml | 2 +- install.sh | 2 +- src/steps/bootstrap.js | 2 +- src/steps/cli-tools.js | 18 +++++++----------- src/utils/shell.js | 2 +- tests/bootstrap.test.js | 2 +- tests/cli-tools.test.js | 23 +++++------------------ tests/configs.test.js | 6 ++++++ tests/shell.test.js | 16 ++++++++++++---- 9 files changed, 35 insertions(+), 38 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 7074405..e434550 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -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' diff --git a/install.sh b/install.sh index 6669977..7ea1976 100644 --- a/install.sh +++ b/install.sh @@ -83,7 +83,7 @@ install_with_manager() { case "${manager}" in brew) - brew install "$@" + brew install --no-ask "$@" ;; apt-get) sudo apt-get update diff --git a/src/steps/bootstrap.js b/src/steps/bootstrap.js index 4ff43c5..f9f39de 100644 --- a/src/steps/bootstrap.js +++ b/src/steps/bootstrap.js @@ -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") { await runStream("sudo apt-get update && sudo apt-get install -y zsh"); } else if (manager === "dnf") { diff --git a/src/steps/cli-tools.js b/src/steps/cli-tools.js index f86af86..f505cf2 100644 --- a/src/steps/cli-tools.js +++ b/src/steps/cli-tools.js @@ -1,5 +1,5 @@ import * as p from "@clack/prompts"; -import { brewInstalled, brewInstall } from "../utils/shell.js"; +import { brewInstall } from "../utils/shell.js"; /** All available CLI tools with metadata. */ export const CLI_TOOLS = { @@ -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}`); } } } diff --git a/src/utils/shell.js b/src/utils/shell.js index 647cbbc..03fa552 100644 --- a/src/utils/shell.js +++ b/src/utils/shell.js @@ -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; diff --git a/tests/bootstrap.test.js b/tests/bootstrap.test.js index bafef55..194975f 100644 --- a/tests/bootstrap.test.js +++ b/tests/bootstrap.test.js @@ -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")); }); test("supports Linux package manager selection", async () => { diff --git a/tests/cli-tools.test.js b/tests/cli-tools.test.js index 618b783..3aad882 100644 --- a/tests/cli-tools.test.js +++ b/tests/cli-tools.test.js @@ -14,24 +14,14 @@ 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); @@ -39,14 +29,11 @@ describe("cli-tools step", () => { 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"); }); }); diff --git a/tests/configs.test.js b/tests/configs.test.js index bd76319..8bd6fa4 100644 --- a/tests/configs.test.js +++ b/tests/configs.test.js @@ -51,6 +51,12 @@ describe("Static config templates", () => { } }); + test("ss alias excludes large directories with the fzf walker", () => { + const content = readFileSync(join(CONFIGS_DIR, "shared", "aliases.zsh"), "utf-8"); + + expect(content).toContain("--walker-skip .git,node_modules"); + }); + test("shared/plugins.zsh exists and has correct content", () => { const file = join(CONFIGS_DIR, "shared", "plugins.zsh"); expect(existsSync(file)).toBe(true); diff --git a/tests/shell.test.js b/tests/shell.test.js index 4586595..64db9c5 100644 --- a/tests/shell.test.js +++ b/tests/shell.test.js @@ -1,14 +1,14 @@ import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, test, vi } from "vitest"; -const spawn = vi.hoisted(() => vi.fn()); +const { execSync, spawn } = vi.hoisted(() => ({ execSync: vi.fn(), spawn: vi.fn() })); vi.mock("node:child_process", () => ({ - execSync: vi.fn(), + execSync, spawn, })); -import { runStream } from "../src/utils/shell.js"; +import { brewInstall, runStream } from "../src/utils/shell.js"; function createChild() { const child = new EventEmitter(); @@ -16,7 +16,7 @@ function createChild() { return child; } -describe("runStream", () => { +describe("shell utilities", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -44,4 +44,12 @@ describe("runStream", () => { command: "sleep 10", }); }); + + test("installs Homebrew packages without prompting", () => { + expect(brewInstall("jq")).toBe(true); + expect(execSync).toHaveBeenCalledWith("brew install --no-ask jq", { stdio: "inherit" }); + + expect(brewInstall("ghostty", { cask: true })).toBe(true); + expect(execSync).toHaveBeenCalledWith("brew install --no-ask --cask ghostty", { stdio: "inherit" }); + }); });