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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ The template should keep the same loading order unless there is a strong reason
`src/steps/zsh-config.js` is responsible for:

- creating `~/.config/zsh/core`, `shared`, and `local`
- copying shipped config files without overwriting user-modified files
- copying or line-merging shipped config files without overwriting user-modified files
- generating `.zshrc` from the selected template
- backing up an existing non-suitup `.zshrc` before overwrite

This step must remain idempotent.

Suitup-managed zsh files support additive line merging with a diff preview and confirmation. Prompt/theme files are intentionally excluded because they often contain generated or user-tuned state that cannot be safely reconciled line by line.

## Testing Notes

- Run `npm test` for the full suite.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Named after Barney's catchphrase from [How I Met Your Mother](https://www.themov
- Suitup is zsh-only; run all suitup commands from a zsh session
- Modular step selection — install only what you need
- **Append mode** — add recommended configs to an existing `.zshrc` without replacing it
- **Diff preview updates** — reruns show safe suitup config additions before applying them
- **Migrate PATH mode** — move PATH/tool bootstrap lines out of `.zshrc` into `~/.config/zsh/core/paths.zsh`
- **Verify mode** — check your installation integrity
- **Clean mode** — remove suitup config files
Expand Down Expand Up @@ -120,6 +121,10 @@ Interactive step-by-step setup with selectable steps:

Before suitup updates shell startup config, it backs up existing zsh startup files such as `.zshrc`, `.zprofile`, `.zshenv`, and `.zlogin` to `~/.config/zsh/backups/`.

When rerunning setup against suitup-managed zsh files, suitup line-merges new shipped additions, renders a unified diff preview, and asks for confirmation before writing. Existing user-added lines are preserved. Files that are not marked as suitup-managed are skipped with a reason because suitup cannot safely distinguish shipped config from user-owned config.

Prompt/theme files are excluded from line-merging because they often contain generated or user-tuned state that cannot be reliably reconciled.

If you choose Powerlevel10k, suitup keeps prompt loading non-interactive during setup. When `~/.p10k.zsh` is missing, it falls back to a basic prompt until you run `p10k configure` yourself.

On Linux, suitup disables zsh spelling correction by default to avoid disruptive IME-related corrections while typing commands.
Expand Down Expand Up @@ -155,6 +160,8 @@ Uses idempotent marker blocks (`# >>> suitup/... >>>`) to safely append selected
- Startup performance monitor
- FZF configuration

For suitup-managed shared config files such as aliases and zinit plugins, append mode also previews safe line additions before applying them. Prompt/theme files are not line-merged because they often contain generated or user-tuned state that cannot be reliably reconciled.

### Verify

```bash
Expand Down
7 changes: 7 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- suitup 只支持 zsh;所有命令都需要在 zsh 会话中运行
- 模块化步骤选择,只安装你需要的内容
- **追加模式**:向现有 `.zshrc` 追加推荐配置,不强制覆盖
- **Diff 预览更新**:重复运行时会先展示安全的 suitup 配置增量,再确认应用
- **PATH 迁移模式**:把 `.zshrc` 里的 PATH / 工具初始化行迁移到 `~/.config/zsh/core/paths.zsh`
- **验证模式**:检查安装完整性
- **清理模式**:删除 suitup 生成的配置
Expand Down Expand Up @@ -120,6 +121,10 @@ node src/cli.js

在 suitup 修改 Shell 启动配置前,会先把现有 `.zshrc`、`.zprofile`、`.zshenv`、`.zlogin` 等 zsh 启动文件备份到 `~/.config/zsh/backups/`。

当对 suitup 管理的 zsh 文件重复运行 setup 时,suitup 会按行合并新版内置配置中的新增内容。它会渲染 unified diff 预览,并在写入前请求确认。用户自己新增的行会被保留。未标记为 suitup 管理的文件会被跳过并说明原因,因为 suitup 无法可靠地区分哪些行来自内置配置、哪些行属于用户自有配置。

Prompt / theme 文件不会做行级合并,因为其中常包含生成内容或用户调校状态,无法可靠自动协调。

如果你选择 Powerlevel10k,suitup 会保持安装过程非交互;当缺少 `~/.p10k.zsh` 时,会先回退到基础 prompt,等你之后自行运行 `p10k configure` 再启用。

在 Linux 上,suitup 默认会关闭 zsh 的拼写纠错,避免输入命令时受到 IME 相关误纠正的干扰。
Expand Down Expand Up @@ -155,6 +160,8 @@ node src/cli.js append
- 启动性能报告
- FZF 配置

对于 aliases、zinit plugins 等 suitup 管理的共享配置文件,append 模式也会在应用安全的行级增量前展示 diff 预览。Prompt / theme 文件不会做行级合并,因为其中常包含生成内容或用户调校状态,无法可靠自动协调。

### Verify(验证)

```bash
Expand Down
19 changes: 14 additions & 5 deletions src/append.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import pc from "picocolors";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { appendIfMissing, ensureDir, readFileSafe, copyFile, copyIfNotExists, writeFile } from "./utils/fs.js";
import { appendIfMissing, ensureDir, readFileSafe, copyIfNotExists, writeFile } from "./utils/fs.js";
import { applyManagedConfigUpdate } from "./utils/config-diff.js";
import { CONFIGS_DIR } from "./constants.js";
import { backupShellRcFiles } from "./steps/zsh-config.js";
import { installZinit } from "./steps/plugin-manager.js";
Expand Down Expand Up @@ -104,9 +105,13 @@ const BLOCKS = [
hint: "source ~/.config/zsh/shared/aliases.zsh",
group: "Suitup Configs",
marker: "suitup/aliases",
apply() {
async apply() {
ensureDir(ZSH_SHARED_DIR);
copyFile(join(CONFIGS_DIR, "shared", "aliases.zsh"), join(ZSH_SHARED_DIR, "aliases.zsh"));
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "shared", "aliases.zsh"),
dest: join(ZSH_SHARED_DIR, "aliases.zsh"),
label: "aliases",
});
return appendIfMissing(
ZSHRC,
'\n# >>> suitup/aliases >>>\nsource_if_exists "$HOME/.config/zsh/shared/aliases.zsh"\n# <<< suitup/aliases <<<\n',
Expand All @@ -120,9 +125,13 @@ const BLOCKS = [
hint: "source ~/.config/zsh/shared/plugins.zsh",
group: "Suitup Configs",
marker: "suitup/zinit-plugins",
apply() {
async apply() {
ensureDir(ZSH_SHARED_DIR);
copyFile(join(CONFIGS_DIR, "shared", "plugins.zsh"), join(ZSH_SHARED_DIR, "plugins.zsh"));
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "shared", "plugins.zsh"),
dest: join(ZSH_SHARED_DIR, "plugins.zsh"),
label: "zinit plugin config",
});
return appendIfMissing(
ZSHRC,
'\n# >>> suitup/zinit-plugins >>>\nsource_if_exists "$HOME/.config/zsh/shared/plugins.zsh"\n# <<< suitup/zinit-plugins <<<\n',
Expand Down
16 changes: 8 additions & 8 deletions src/steps/aliases.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as p from "@clack/prompts";
import { homedir } from "node:os";
import { join } from "node:path";
import { copyIfNotExists, ensureDir } from "../utils/fs.js";
import { ensureDir } from "../utils/fs.js";
import { applyManagedConfigUpdate } from "../utils/config-diff.js";
import { CONFIGS_DIR } from "../constants.js";

/**
Expand All @@ -13,10 +13,10 @@ export async function setupAliases({ home } = {}) {
const base = home || homedir();
const dest = join(base, ".config", "zsh", "shared", "aliases.zsh");
ensureDir(join(base, ".config", "zsh", "shared"));
const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", "aliases.zsh"), dest);
if (copied) {
p.log.success("Aliases written to ~/.config/zsh/shared/aliases.zsh");
} else {
p.log.info("Aliases already exist at ~/.config/zsh/shared/aliases.zsh, skipped");
}
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "shared", "aliases.zsh"),
dest,
label: "aliases",
home: base,
});
}
16 changes: 8 additions & 8 deletions src/steps/plugin-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { runStream } from "../utils/shell.js";
import { copyIfNotExists, ensureDir } from "../utils/fs.js";
import { ensureDir } from "../utils/fs.js";
import { applyManagedConfigUpdate } from "../utils/config-diff.js";
import { CONFIGS_DIR } from "../constants.js";

/**
Expand All @@ -29,13 +30,12 @@ export async function installZinit({ home } = {}) {
p.log.success("zinit installed");
}

// Copy plugin config (skip if already exists)
const dest = join(base, ".config", "zsh", "shared", "plugins.zsh");
ensureDir(join(base, ".config", "zsh", "shared"));
const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", "plugins.zsh"), dest);
if (copied) {
p.log.success("zinit plugin config written to ~/.config/zsh/shared/plugins.zsh");
} else {
p.log.info("zinit plugin config already exists, skipped");
}
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "shared", "plugins.zsh"),
dest,
label: "zinit plugin config",
home: base,
});
}
48 changes: 34 additions & 14 deletions src/steps/zsh-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as p from "@clack/prompts";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { copyFile, copyIfNotExists, ensureDir, writeIfNotExists, readFileSafe, writeFile } from "../utils/fs.js";
import { copyFile, ensureDir, writeIfNotExists, readFileSafe, writeFile } from "../utils/fs.js";
import { applyManagedConfigUpdate } from "../utils/config-diff.js";
import { CONFIGS_DIR } from "../constants.js";

const SHELL_RC_FILES = [
Expand Down Expand Up @@ -59,8 +60,12 @@ export async function setupZshConfig({ home, promptTheme = "p10k" } = {}) {
// Copy core configs (skip if already exist)
const coreFiles = ["perf.zsh", "env.zsh", "paths.zsh", "options.zsh"];
for (const file of coreFiles) {
const copied = copyIfNotExists(join(CONFIGS_DIR, "core", file), join(zshConfig, "core", file));
if (!copied) p.log.info(`Skipped core/${file} (already exists)`);
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "core", file),
dest: join(zshConfig, "core", file),
label: `core/${file}`,
home: base,
});
}

// Copy shared configs (skip if already exist)
Expand All @@ -70,8 +75,12 @@ export async function setupZshConfig({ home, promptTheme = "p10k" } = {}) {
"highlighting.zsh",
];
for (const file of sharedFiles) {
const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", file), join(zshConfig, "shared", file));
if (!copied) p.log.info(`Skipped shared/${file} (already exists)`);
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "shared", file),
dest: join(zshConfig, "shared", file),
label: `shared/${file}`,
home: base,
});
}

// Copy shared/tools/ module files (skip if already exist)
Expand All @@ -83,17 +92,21 @@ export async function setupZshConfig({ home, promptTheme = "p10k" } = {}) {
"bun.zsh",
];
for (const file of toolFiles) {
const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", "tools", file), join(zshConfig, "shared", "tools", file));
if (!copied) p.log.info(`Skipped shared/tools/${file} (already exists)`);
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "shared", "tools", file),
dest: join(zshConfig, "shared", "tools", file),
label: `shared/tools/${file}`,
home: base,
});
}

const promptSource = promptTheme === "basic" ? "prompt-basic.zsh" : "prompt.zsh";
const promptDest = join(zshConfig, "shared", "prompt.zsh");
const existingPrompt = readFileSafe(promptDest);
if (!existingPrompt || existingPrompt.includes("Generated by suitup")) {
if (!existingPrompt) {
copyFile(join(CONFIGS_DIR, "shared", promptSource), promptDest);
} else {
p.log.info("Skipped shared/prompt.zsh (already exists)");
p.log.info("Skipped shared/prompt.zsh: prompt files are not line-merged to preserve user customizations.");
}

// Create local placeholder (don't overwrite existing)
Expand Down Expand Up @@ -124,9 +137,12 @@ export async function writeZshrc(pluginManager = "zinit", { home } = {}) {
if (existsSync(zshrc)) {
const existing = readFileSafe(zshrc);
if (existing.includes("Generated by suitup")) {
// Already a suitup-managed zshrc, overwrite
writeFile(zshrc, template);
p.log.success(".zshrc updated (suitup-managed)");
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "zshrc.template"),
dest: zshrc,
label: ".zshrc",
home: base,
});
return;
}

Expand Down Expand Up @@ -163,8 +179,12 @@ export async function writeZshenv({ home } = {}) {
if (existsSync(zshenv)) {
const existing = readFileSafe(zshenv);
if (existing.includes("Generated by suitup")) {
writeFile(zshenv, template);
p.log.success(".zshenv updated (suitup-managed)");
await applyManagedConfigUpdate({
source: join(CONFIGS_DIR, "zshenv.template"),
dest: zshenv,
label: ".zshenv",
home: base,
});
return;
}
p.log.info(".zshenv exists and is not managed by suitup, skipped");
Expand Down
Loading
Loading