diff --git a/README.md b/README.md index 2155f92..c5935d2 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ 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: @@ -58,13 +58,13 @@ You can also pass a command directly to skip the prompt: 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 @@ -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` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 3b5abae..29d5720 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,7 +50,7 @@ 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,不再依赖某个发行版的软件源。 你也可以直接传入命令来跳过询问: @@ -58,13 +58,13 @@ curl -fsSL https://raw.githubusercontent.com/ChangeHow/suitup/main/install.sh | 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` 完成提示符主题配置 @@ -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` | diff --git a/install.sh b/install.sh index 7ea1976..48e781b 100644 --- a/install.sh +++ b/install.sh @@ -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 echo '' >&2 read -r -p 'Enter 1 or 2 [default: 1]: ' _suitup_choice < /dev/tty case "${_suitup_choice}" in diff --git a/src/cli-config.js b/src/cli-config.js index 4f8d436..89cc8fe 100644 --- a/src/cli-config.js +++ b/src/cli-config.js @@ -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 diff --git a/src/cli.js b/src/cli.js index a568d0d..ecbf2be 100644 --- a/src/cli.js +++ b/src/cli.js @@ -29,7 +29,7 @@ export async function main(argv = process.argv) { await runSetup(); break; case "init": - await runSetup({ defaults: true }); + await runSetup(); break; case "append": await runAppend(); diff --git a/src/setup.js b/src/setup.js index 164ec62..fbe86b7 100644 --- a/src/setup.js +++ b/src/setup.js @@ -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(); } diff --git a/src/steps/zsh-config.js b/src/steps/zsh-config.js index d3e724d..abdad64 100644 --- a/src/steps/zsh-config.js +++ b/src/steps/zsh-config.js @@ -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"; } } diff --git a/src/utils/shell.js b/src/utils/shell.js index 03fa552..49d3d60 100644 --- a/src/utils/shell.js +++ b/src/utils/shell.js @@ -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; } } diff --git a/tests/cli-main.test.js b/tests/cli-main.test.js index 1dda4e5..88e13c5 100644 --- a/tests/cli-main.test.js +++ b/tests/cli-main.test.js @@ -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 () => { diff --git a/tests/install-script.test.js b/tests/install-script.test.js index e54dadb..bef1eea 100644 --- a/tests/install-script.test.js +++ b/tests/install-script.test.js @@ -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", () => { diff --git a/tests/perf.zsh b/tests/perf.zsh index 3bb611f..002cee2 100644 --- a/tests/perf.zsh +++ b/tests/perf.zsh @@ -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" diff --git a/tests/setup-flow.test.js b/tests/setup-flow.test.js index 1e5d9b0..a7be2b3 100644 --- a/tests/setup-flow.test.js +++ b/tests/setup-flow.test.js @@ -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"]); @@ -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 }); diff --git a/tests/shell.test.js b/tests/shell.test.js index 64db9c5..8e00c1b 100644 --- a/tests/shell.test.js +++ b/tests/shell.test.js @@ -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 })); + }); }); diff --git a/tests/zsh-config-steps.test.js b/tests/zsh-config-steps.test.js index 1827460..0521091 100644 --- a/tests/zsh-config-steps.test.js +++ b/tests/zsh-config-steps.test.js @@ -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"; @@ -26,6 +28,7 @@ describe("zsh-config step", () => { beforeEach(() => { vi.clearAllMocks(); + mockIsCancel.mockReturnValue(false); sandbox = createSandbox(); }); @@ -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);