阅读视图

发现新文章,点击刷新页面。

Fork 主题如何更新?基于 Ink 构建主题更新 CLI 工具

本文地址:blog.cosine.ren/post/intera…

本文图表、伪代码等由 AI 辅助编写

背景

当你 fork 了一个开源项目作为自己的博客主题,如何优雅地从上游仓库同步更新?手动敲一串 Git 命令既繁琐又容易出错;但直接点 Fork 的 Sync 按钮,又可能覆盖你的自定义配置和内容。

很多人因此在「保持更新」和「保留修改」之间左右为难:要么干脆二开后不再同步,要么每次更新都提心吊胆。

这也是为什么不少项目会像 @fumadocs/cli 一样,提供专门的 CLI 来完成更新等相关操作。

本文将介绍如何简单地构建一个交互式 CLI 工具,把 fork 同步的流程自动化起来。

这个工具的核心目标是:

  • 安全:更新前检查工作区状态,必要时可备份
  • 透明:预览所有变更,让用户决定是否更新
  • 友好:出现冲突时给出明确指引

具体的代码可以看这个 PR:

github.com/cosZone/ast…

不过这个 PR 只是最初的版本,后面又缝缝补补了不少东西,整体流程是我研究一个周末后摸索出的,如有不足,那一定是我考虑不周,欢迎指出~

在这个 PR 里,我基于 Ink 构建了一个交互式 TUI 工具,提供了博客内容备份/还原、主题更新、内容生成、备份管理等功能:

pnpm koharu # 交互式主菜单
pnpm koharu backup # 备份博客内容 (--full 完整备份)
pnpm koharu restore # 还原备份 (--latest, --dry-run, --force)
pnpm koharu update # 从上游同步更新 (--check, --skip-backup, --force)
pnpm koharu generate # 生成内容资产 (LQIP, 相似度, AI 摘要)
pnpm koharu clean # 清理旧备份 (--keep N)
pnpm koharu list # 查看所有备份

其中备份功能可以:

  • 基础备份:博客文章、配置、头像、.env
  • 完整备份:包含所有图片和生成的资产文件
  • 自动生成 manifest.json 记录主题版本与备份元信息(时间等)

还原功能可以:

  • 交互式选择备份文件
  • 支持 --dry-run 预览模式
  • 显示备份类型、版本、时间等元信息

主题更新功能可以:

  • 自动配置 upstream remote 指向原始仓库
  • 预览待合并的提交列表(显示 hash、message、时间)
  • 更新前可选备份,支持冲突检测与处理
  • 合并成功后自动安装依赖
  • 支持 --check 仅检查更新、--force 跳过工作区检查

整体架构

infographic sequence-snake-steps-underline-text
data
  title Git Update 命令流程
  desc 从 upstream 同步更新的完整工作流
  items
    - label 检查状态
      desc 验证当前分支和工作区状态
      icon mdi/source-branch-check
    - label 配置远程
      desc 确保 upstream remote 已配置
      icon mdi/source-repository
    - label 获取更新
      desc 从 upstream 拉取最新提交
      icon mdi/cloud-download
    - label 预览变更
      desc 显示待合并的提交列表
      icon mdi/file-find
    - label 确认备份
      desc 可选:备份当前内容
      icon mdi/backup-restore
    - label 执行合并
      desc 合并 upstream 分支到本地
      icon mdi/merge
    - label 处理结果
      desc 成功则安装依赖,冲突则提示解决
      icon mdi/check-circle

更新相关 Git 命令详解

1. 检查当前分支

git rev-parse --abbrev-ref HEAD

作用:获取当前所在分支的名称。

参数解析

  • rev-parse:解析 Git 引用
  • --abbrev-ref:输出简短的引用名称(如 main),而不是完整的 SHA

使用场景:确保用户在正确的分支(如 main)上执行更新,避免在 feature 分支上意外合并上游代码。

const currentBranch = execSync("git rev-parse --abbrev-ref HEAD")
  .toString()
  .trim();
if (currentBranch !== "main") {
  throw new Error(`仅支持在 main 分支执行更新,当前分支: ${currentBranch}`);
}

2. 检查工作区状态

git status --porcelain

作用:以机器可读的格式输出工作区状态。

参数解析

  • --porcelain:输出稳定、易于解析的格式,不受 Git 版本和语言设置影响

输出格式

M  modified-file.ts      # 已暂存的修改
 M unstaged-file.ts      # 未暂存的修改
?? untracked-file.ts     # 未跟踪的文件
A  new-file.ts           # 新添加的文件
D  deleted-file.ts       # 删除的文件

前两个字符分别表示暂存区和工作区的状态。

const statusOutput = execSync("git status --porcelain").toString();
const uncommittedFiles = statusOutput.split("\n").filter((line) => line.trim());
const isClean = uncommittedFiles.length === 0;

3. 管理远程仓库

检查 remote 是否存在

git remote get-url upstream

作用:获取指定 remote 的 URL,如果不存在会报错。

添加 upstream remote

# 将 URL 替换为你的上游仓库地址
git remote add upstream https://github.com/original/repo.git

作用:添加一个名为 upstream 的远程仓库,指向原始项目。

为什么需要 upstream?

当你 fork 一个项目后,你的 origin 指向你自己的 fork,而 upstream 指向原始项目。这样可以:

  • upstream 拉取原项目的更新
  • origin 推送你的修改
// UPSTREAM_URL 需替换为你的上游仓库地址
const UPSTREAM_URL = "https://github.com/original/repo.git";

function ensureUpstreamRemote(): string {
  try {
    return execSync("git remote get-url upstream").toString().trim();
  } catch {
    execSync(`git remote add upstream ${UPSTREAM_URL}`);
    return UPSTREAM_URL;
  }
}

4. 获取远程更新

git fetch upstream

作用:从 upstream 远程仓库下载所有分支的最新提交,但不会自动合并到本地分支。

git pull 的区别

  • fetch 只下载数据,不修改本地代码
  • pull = fetch + merge,会自动合并

使用 fetch 可以让我们先预览变更,再决定是否合并。

5. 计算提交差异

git rev-list --left-right --count HEAD...upstream/main

作用:计算本地分支与 upstream/main 之间的提交差异。

参数解析

  • rev-list:列出提交记录
  • --left-right:区分左侧(本地)和右侧(远程)的提交
  • --count:只输出计数,不列出具体提交
  • HEAD...upstream/main:三个点表示对称差集

输出示例

2    5

表示本地有 2 个提交不在 upstream 上(ahead),upstream 有 5 个提交不在本地(behind)。

const revList = execSync(
  "git rev-list --left-right --count HEAD...upstream/main"
)
  .toString()
  .trim();
const [aheadStr, behindStr] = revList.split("\t");
const aheadCount = parseInt(aheadStr, 10);
const behindCount = parseInt(behindStr, 10);

console.log(`本地领先 ${aheadCount} 个提交,落后 ${behindCount} 个提交`);

6. 查看待合并的提交

git log HEAD..upstream/main --pretty=format:"%h|%s|%ar|%an" --no-merges

作用:列出 upstream/main 上有但本地没有的提交。

参数解析

  • HEAD..upstream/main:两个点表示 A 到 B 的差集(B 有而 A 没有的)
  • --pretty=format:"...":自定义输出格式
    • %h:短 hash
    • %s:提交信息
    • %ar:相对时间(如 "2 days ago")
    • %an:作者名
  • --no-merges:排除 merge commit

输出示例

a1b2c3d|feat: add dark mode|2 days ago|Author Name
e4f5g6h|fix: typo in readme|3 days ago|Author Name
const commitFormat = "%h|%s|%ar|%an";
const output = execSync(
  `git log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
).toString();

const commits = output
  .split("\n")
  .filter(Boolean)
  .map((line) => {
    const [hash, message, date, author] = line.split("|");
    return { hash, message, date, author };
  });

7. 查看远程文件内容

git show upstream/main:package.json

作用:直接查看远程分支上某个文件的内容,无需切换分支或合并。

使用场景:获取上游仓库的版本号,用于显示"将更新到 x.x.x 版本"。

const packageJson = execSync("git show upstream/main:package.json").toString();
const { version } = JSON.parse(packageJson);
console.log(`最新版本: ${version}`);

8. 执行合并

git merge upstream/main --no-edit

作用:将 upstream/main 分支合并到当前分支。

参数解析

  • --no-edit:使用自动生成的合并提交信息,不打开编辑器

合并策略:Git 会自动选择合适的合并策略:

  • Fast-forward:如果本地没有新提交,直接移动指针
  • Three-way merge:如果有分叉,创建一个合并提交

注意:本工具采用 merge 同步上游,保留本地历史。如果你的需求是"强制与上游一致"(丢弃本地修改),需要使用 rebase 或 reset 方案,不在本文讨论范围。

9. 检测合并冲突

git diff --name-only --diff-filter=U

作用:列出所有未解决冲突的文件。

参数解析

  • --name-only:只输出文件名
  • --diff-filter=U:只显示 Unmerged(未合并/冲突)的文件

另一种方式是解析 git status --porcelain 的输出,查找冲突标记:

const statusOutput = execSync("git status --porcelain").toString();
const conflictFiles = statusOutput
  .split("\n")
  .filter((line) => {
    const status = line.slice(0, 2);
    // U = Unmerged, AA = both added, DD = both deleted
    return status.includes("U") || status === "AA" || status === "DD";
  })
  // 注:为简化展示,这里直接截取路径
  // 若需完整兼容重命名/特殊路径,应使用更严格的 porcelain 解析
  .map((line) => line.slice(3).trim());

10. 中止合并

git merge --abort

作用:中止当前的合并操作,恢复到合并前的状态。

使用场景:当用户遇到冲突但不想手动解决时,可以选择中止合并。

function abortMerge(): boolean {
  try {
    execSync("git merge --abort");
    return true;
  } catch {
    return false;
  }
}

状态机设计

如果是简单粗暴的使用 useEffect 的话,会出现很多 useEffect 那自然很不好。

整个更新流程使用简单的 useReducer + Effect Map 模式管理,将状态转换逻辑和副作用处理分离,确保流程清晰可控。

为什么不用 Redux?

在设计 CLI 状态管理时,很自然会想到 Redux,毕竟它是 React 生态中最成熟的状态管理方案,而且还是用着 Ink 来进行开发的。但对于 CLI 工具,useReducer 是更合适的选择,理由如下:

  1. 状态作用域单一:CLI 工具通常是单组件树结构,不存在跨页面、跨路由的状态共享需求,
  2. 无需 Middleware 生态:Redux 的强大之处在于中间件生态(redux-thunk、redux-saga、redux-observable),用于处理复杂的异步流程。但我们的场景不需要那么复杂。
  3. 依赖最小化:CLI 工具应该快速启动、轻量运行useReducer 内置于 React,不会引入额外依赖(当然 React 本身也是依赖,不过我的项目里本来就需要它)

总之,对这个场景来说 Redux 有点"过度设计"。

那咋整?

  • Reducer:集中管理所有状态转换逻辑,纯函数易于测试
  • Effect Map:状态到副作用的映射,统一处理异步操作
  • 单一 Effect:一个 useEffect 驱动整个流程

下面是完整的状态转换流程图,展示了所有可能的状态转换路径和条件分支:

注意:Mermaid stateDiagram 中状态名不能包含连字符 -,这里使用 camelCase 命名。

stateDiagram-v2
    [*] --> checking: 开始更新

    checking --> error: 不在 main 分支
    checking --> dirtyWarning: 工作区不干净 && !force
    checking --> fetching: 工作区干净 || force

    dirtyWarning --> [*]: 用户取消
    dirtyWarning --> fetching: 用户继续

    fetching --> upToDate: behindCount = 0
    fetching --> backupConfirm: behindCount > 0 && !skipBackup
    fetching --> preview: behindCount > 0 && skipBackup

    backupConfirm --> backingUp: 用户确认备份
    backupConfirm --> preview: 用户跳过备份

    backingUp --> preview: 备份完成
    backingUp --> error: 备份失败

    preview --> [*]: checkOnly 模式
    preview --> merging: 用户确认更新
    preview --> [*]: 用户取消

    merging --> conflict: 合并冲突
    merging --> installing: 合并成功

    conflict --> [*]: 用户处理冲突

    installing --> done: 依赖安装成功
    installing --> error: 依赖安装失败

    done --> [*]
    error --> [*]
    upToDate --> [*]

类型定义

// 12 种状态覆盖完整流程
type UpdateStatus =
  | "checking" // 检查 Git 状态
  | "dirty-warning" // 工作区有未提交更改
  | "backup-confirm" // 确认备份
  | "backing-up" // 正在备份
  | "fetching" // 获取更新
  | "preview" // 显示更新预览
  | "merging" // 合并中
  | "installing" // 安装依赖
  | "done" // 完成
  | "conflict" // 有冲突
  | "up-to-date" // 已是最新
  | "error"; // 错误

// Action 驱动状态转换
type UpdateAction =
  | { type: "GIT_CHECKED"; payload: GitStatusInfo }
  | { type: "FETCHED"; payload: UpdateInfo }
  | { type: "BACKUP_CONFIRM" | "BACKUP_SKIP" | "UPDATE_CONFIRM" | "INSTALLED" }
  | { type: "BACKUP_DONE"; backupFile: string }
  | { type: "MERGED"; payload: MergeResult }
  | { type: "ERROR"; error: string };

Reducer 集中状态转换

所有状态转换逻辑集中在 reducer 中,每个 case 只处理当前状态下合法的 action:

function updateReducer(state: UpdateState, action: UpdateAction): UpdateState {
  const { status, options } = state;

  // 通用错误处理:任何状态都可以转到 error
  if (action.type === "ERROR") {
    return { ...state, status: "error", error: action.error };
  }

  switch (status) {
    case "checking": {
      if (action.type !== "GIT_CHECKED") return state;
      const { payload: gitStatus } = action;

      if (gitStatus.currentBranch !== "main") {
        return {
          ...state,
          status: "error",
          error: "仅支持在 main 分支执行更新",
        };
      }
      if (!gitStatus.isClean && !options.force) {
        return { ...state, status: "dirty-warning", gitStatus };
      }
      return { ...state, status: "fetching", gitStatus };
    }

    case "fetching": {
      if (action.type !== "FETCHED") return state;
      const { payload: updateInfo } = action;

      if (updateInfo.behindCount === 0) {
        return { ...state, status: "up-to-date", updateInfo };
      }
      const nextStatus = options.skipBackup ? "preview" : "backup-confirm";
      return { ...state, status: nextStatus, updateInfo };
    }

    // ... 其他状态处理
  }
}

Effect Map:统一副作用处理

每个需要执行副作用的状态对应一个 effect 函数,可返回 cleanup 函数:

type EffectFn = (
  state: UpdateState,
  dispatch: Dispatch<UpdateAction>
) => (() => void) | undefined;

const statusEffects: Partial<Record<UpdateStatus, EffectFn>> = {
  checking: (_state, dispatch) => {
    const gitStatus = checkGitStatus();
    ensureUpstreamRemote();
    dispatch({ type: "GIT_CHECKED", payload: gitStatus });
    return undefined;
  },

  fetching: (_state, dispatch) => {
    fetchUpstream();
    const info = getUpdateInfo();
    dispatch({ type: "FETCHED", payload: info });
    return undefined;
  },

  installing: (_state, dispatch) => {
    let cancelled = false;
    installDeps().then((result) => {
      if (cancelled) return;
      dispatch(
        result.success
          ? { type: "INSTALLED" }
          : { type: "ERROR", error: result.error }
      );
    });
    return () => {
      cancelled = true;
    }; // cleanup
  },
};

组件使用

组件中只需一个核心 useEffect 来驱动整个状态机:

function UpdateApp({ checkOnly, skipBackup, force }) {
  const [state, dispatch] = useReducer(
    updateReducer,
    { checkOnly, skipBackup, force },
    createInitialState
  );

  // 核心:单一 effect 处理所有副作用
  useEffect(() => {
    const effect = statusEffects[state.status];
    if (!effect) return;
    return effect(state, dispatch);
  }, [state.status, state]);

  // UI 渲染基于 state.status
  return <Box>...</Box>;
}

这种模式的优势:

  • 可测试性:Reducer 是纯函数,可以独立测试状态转换
  • 可维护性:状态逻辑集中,不会分散在多个 useEffect
  • 可扩展性:添加新状态只需在 reducer 和 effect map 各加一个 case

用户交互设计

使用 React Ink 构建终端 UI,提供友好的交互体验:

预览更新

发现 5 个新提交:
  a1b2c3d feat: add dark mode (2 days ago)
  e4f5g6h fix: responsive layout (3 days ago)
  i7j8k9l docs: update readme (1 week ago)
  ... 还有 2 个提交

注意: 本地有 1 个未推送的提交

确认更新到最新版本? (Y/n)

处理冲突

发现合并冲突
冲突文件:
  - src/config.ts
  - src/components/Header.tsx

你可以:
  1. 手动解决冲突后运行: git add . && git commit
  2. 中止合并恢复到更新前状态

备份文件: backup-2026-01-10-full.tar.gz

是否中止合并? (Y/n)

完整代码实现

Git 操作封装

import { execSync } from "node:child_process";

function git(args: string): string {
  return execSync(`git ${args}`, {
    encoding: "utf-8",
    stdio: ["pipe", "pipe", "pipe"],
  }).trim();
}

function gitSafe(args: string): string | null {
  try {
    return git(args);
  } catch {
    return null;
  }
}

export function checkGitStatus(): GitStatusInfo {
  const currentBranch = git("rev-parse --abbrev-ref HEAD");
  const statusOutput = gitSafe("status --porcelain") || "";
  const uncommittedFiles = statusOutput
    .split("\n")
    .filter((line) => line.trim());

  return {
    currentBranch,
    isClean: uncommittedFiles.length === 0,
    // 注:简化处理,完整兼容需更严格的 porcelain 解析
    uncommittedFiles: uncommittedFiles.map((line) => line.slice(3).trim()),
  };
}

export function getUpdateInfo(): UpdateInfo {
  const revList =
    gitSafe("rev-list --left-right --count HEAD...upstream/main") || "0\t0";
  const [aheadStr, behindStr] = revList.split("\t");

  const commitFormat = "%h|%s|%ar|%an";
  const commitsOutput =
    gitSafe(
      `log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
    ) || "";

  const commits = commitsOutput
    .split("\n")
    .filter(Boolean)
    .map((line) => {
      const [hash, message, date, author] = line.split("|");
      return { hash, message, date, author };
    });

  return {
    behindCount: parseInt(behindStr, 10),
    aheadCount: parseInt(aheadStr, 10),
    commits,
  };
}

export function mergeUpstream(): MergeResult {
  try {
    git("merge upstream/main --no-edit");
    return { success: true, hasConflict: false, conflictFiles: [] };
  } catch {
    const conflictFiles = getConflictFiles();
    return {
      success: false,
      hasConflict: conflictFiles.length > 0,
      conflictFiles,
    };
  }
}

function getConflictFiles(): string[] {
  const output = gitSafe("diff --name-only --diff-filter=U") || "";
  return output.split("\n").filter(Boolean);
}

Git 命令速查表

命令 作用 场景
git rev-parse --abbrev-ref HEAD 获取当前分支名 验证分支
git status --porcelain 机器可读的状态输出 检查工作区
git remote get-url <name> 获取 remote URL 检查 remote
git remote add <name> <url> 添加 remote 配置 upstream
git fetch <remote> 下载远程更新 获取更新
git rev-list --left-right --count A...B 统计差异提交数 计算 ahead/behind
git log A..B --pretty=format:"..." 列出差异提交 预览更新
git show <ref>:<path> 查看远程文件 获取版本号
git merge <branch> --no-edit 自动合并 执行更新
git diff --name-only --diff-filter=U 列出冲突文件 检测冲突
git merge --abort 中止合并 回滚操作

Git 命令功能分类

为了更好地理解这些命令的用途,下面按功能将它们分类展示:

infographic hierarchy-structure
data
  title Git 命令功能分类
  desc 按操作类型组织的命令清单
  items
    - label 状态检查
      icon mdi/information
      children
        - label git rev-parse
          desc 获取当前分支名
        - label git status --porcelain
          desc 检查工作区状态
    - label 远程管理
      icon mdi/server-network
      children
        - label git remote get-url
          desc 检查 remote 是否存在
        - label git remote add
          desc 添加 upstream remote
        - label git fetch
          desc 下载远程更新
    - label 提交分析
      icon mdi/source-commit
      children
        - label git rev-list
          desc 统计提交差异
        - label git log
          desc 查看提交历史
        - label git show
          desc 查看远程文件内容
    - label 合并操作
      icon mdi/source-merge
      children
        - label git merge
          desc 执行分支合并
        - label git merge --abort
          desc 中止合并恢复状态
    - label 冲突检测
      icon mdi/alert-octagon
      children
        - label git diff --diff-filter=U
          desc 列出未解决冲突文件

备份还原功能实现

除了主题更新,CLI 还提供了完整的备份还原功能,确保用户数据安全。

备份和还原是两个互补的操作,下图展示了它们的完整工作流:

infographic compare-hierarchy-row-letter-card-compact-card
data
  title 备份与还原流程对比
  desc 两个互补操作的完整工作流
  items
    - label 备份流程
      icon mdi/backup-restore
      children
        - label 检查配置
          desc 确定备份类型和范围
        - label 创建临时目录
          desc 准备暂存空间
        - label 复制文件
          desc 按配置复制所需文件
        - label 生成 manifest
          desc 记录版本和元信息
        - label 压缩打包
          desc tar.gz 压缩存档
        - label 清理临时目录
          desc 删除暂存目录
    - label 还原流程
      icon mdi/restore
      children
        - label 选择备份
          desc 读取 manifest 显示备份信息
        - label 解压到临时目录
          desc 提取归档内容(包含 manifest)
        - label 读取 manifest.files
          desc 获取实际备份成功的文件列表
        - label 按映射复制文件
          desc 使用自动生成的 RESTORE_MAP
        - label 清理临时目录
          desc 删除解压的暂存文件

备份项配置

备份系统采用配置驱动的方式,定义需要备份的文件和目录:

export interface BackupItem {
  src: string; // 源路径(相对于项目根目录)
  dest: string; // 备份内目标路径
  label: string; // 显示标签
  required: boolean; // 是否为必需项(basic 模式包含)
}

export const BACKUP_ITEMS: BackupItem[] = [
  // 基础备份项(required: true)
  {
    src: "src/content/blog",
    dest: "content/blog",
    label: "博客文章",
    required: true,
  },
  {
    src: "config/site.yaml",
    dest: "config/site.yaml",
    label: "网站配置",
    required: true,
  },
  {
    src: "src/pages/about.md",
    dest: "pages/about.md",
    label: "关于页面",
    required: true,
  },
  {
    src: "public/img/avatar.webp",
    dest: "img/avatar.webp",
    label: "用户头像",
    required: true,
  },
  { src: ".env", dest: "env", label: "环境变量", required: true },
  // 完整备份额外项目(required: false)
  { src: "public/img", dest: "img", label: "所有图片", required: false },
  {
    src: "src/assets/lqips.json",
    dest: "assets/lqips.json",
    label: "LQIP 数据",
    required: false,
  },
  {
    src: "src/assets/similarities.json",
    dest: "assets/similarities.json",
    label: "相似度数据",
    required: false,
  },
  {
    src: "src/assets/summaries.json",
    dest: "assets/summaries.json",
    label: "AI 摘要数据",
    required: false,
  },
];

备份流程

备份操作使用 tar.gz 格式压缩,并生成 manifest.json 记录元信息:

export function runBackup(
  isFullBackup: boolean,
  onProgress?: (results: BackupResult[]) => void
): BackupOutput {
  // 1. 创建备份目录和临时目录
  fs.mkdirSync(BACKUP_DIR, { recursive: true });
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
  const tempDir = path.join(BACKUP_DIR, `.tmp-backup-${timestamp}`);

  // 2. 过滤备份项目(基础备份只包含 required: true 的项目)
  const itemsToBackup = BACKUP_ITEMS.filter(
    (item) => item.required || isFullBackup
  );

  // 3. 复制文件到临时目录
  const results: BackupResult[] = [];
  for (const item of itemsToBackup) {
    const srcPath = path.join(PROJECT_ROOT, item.src);
    const destPath = path.join(tempDir, item.dest);

    if (fs.existsSync(srcPath)) {
      fs.cpSync(srcPath, destPath, { recursive: true });
      results.push({ item, success: true, skipped: false });
    } else {
      results.push({ item, success: false, skipped: true });
    }
    onProgress?.([...results]); // 进度回调
  }

  // 4. 生成 manifest.json
  const manifest = {
    name: "astro-koharu-backup",
    version: getVersion(),
    type: isFullBackup ? "full" : "basic",
    timestamp,
    created_at: new Date().toISOString(),
    files: Object.fromEntries(results.map((r) => [r.item.dest, r.success])),
  };
  fs.writeFileSync(
    path.join(tempDir, "manifest.json"),
    JSON.stringify(manifest, null, 2)
  );

  // 5. 压缩并清理
  tarCreate(backupFilePath, tempDir);
  fs.rmSync(tempDir, { recursive: true, force: true });

  return { results, backupFile: backupFilePath, fileSize, timestamp };
}

tar 操作封装

使用系统 tar 命令进行压缩和解压,并添加路径遍历安全检查:

// 安全验证:防止路径遍历攻击
function validateTarEntries(entries: string[], archivePath: string): void {
  for (const entry of entries) {
    if (entry.includes("\0")) {
      throw new Error(`tar entry contains null byte`);
    }
    const normalized = path.posix.normalize(entry);
    if (path.posix.isAbsolute(normalized)) {
      throw new Error(`tar entry is absolute path: ${entry}`);
    }
    if (normalized.split("/").includes("..")) {
      throw new Error(`tar entry contains parent traversal: ${entry}`);
    }
  }
}

// 创建压缩包
export function tarCreate(archivePath: string, sourceDir: string): void {
  spawnSync("tar", ["-czf", archivePath, "-C", sourceDir, "."]);
}

// 解压到指定目录
export function tarExtract(archivePath: string, destDir: string): void {
  listTarEntries(archivePath); // 先验证条目安全性
  spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
}

// 读取 manifest(不解压整个文件)
export function tarExtractManifest(archivePath: string): string | null {
  const result = spawnSync("tar", ["-xzf", archivePath, "-O", "manifest.json"]);
  return result.status === 0 ? result.stdout : null;
}

还原流程

还原操作基于 manifest 驱动,确保只还原实际备份成功的文件:

// 路径映射:从备份项配置自动生成,确保一致性
export const RESTORE_MAP: Record<string, string> = Object.fromEntries(
  BACKUP_ITEMS.map((item) => [item.dest, item.src])
);

export function restoreBackup(backupPath: string): RestoreResult {
  // 1. 创建临时目录并解压
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
  tarExtract(backupPath, tempDir);

  // 2. 读取 manifest 获取实际备份的文件列表
  const manifestPath = path.join(tempDir, "manifest.json");
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));

  const restored: string[] = [];
  const skipped: string[] = [];

  // 3. 基于 manifest.files 还原(只还原成功备份的文件)
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    // 跳过备份失败的文件
    if (!success) {
      skipped.push(backupPath);
      continue;
    }

    const projectPath = RESTORE_MAP[backupPath];
    if (!projectPath) {
      console.warn(`未知的备份路径: ${backupPath},跳过`);
      skipped.push(backupPath);
      continue;
    }

    const srcPath = path.join(tempDir, backupPath);
    const destPath = path.join(PROJECT_ROOT, projectPath);

    if (fs.existsSync(srcPath)) {
      fs.mkdirSync(path.dirname(destPath), { recursive: true });
      fs.cpSync(srcPath, destPath, { recursive: true });
      restored.push(projectPath);
    } else {
      skipped.push(backupPath);
    }
  }

  // 4. 清理临时目录
  fs.rmSync(tempDir, { recursive: true, force: true });

  return {
    restored,
    skipped,
    backupType: manifest.type,
    version: manifest.version,
  };
}

Dry-Run 模式详解

Dry-run(预演模式)是 CLI 工具中常见的安全特性,允许用户在实际执行前预览操作结果。本实现采用函数分离 + 条件渲染的模式。

下图展示了预览模式和实际执行模式的核心区别:

infographic compare-binary-horizontal-badge-card-arrow
data
  title Dry-Run 模式与实际执行对比
  desc 预览模式和实际还原的关键区别
  items
    - label 预览模式
      desc 安全的只读预览
      icon mdi/eye
      children
        - label 提取 manifest.json
          desc 调用 tarExtractManifest 不解压整个归档
        - label 读取 manifest.files
          desc 获取实际备份的文件列表
        - label 统计文件数量
          desc 调用 tarList 计算每个路径的文件数
        - label 不修改任何文件
          desc 零副作用,可安全执行
    - label 实际执行
      desc 基于 manifest 的还原
      icon mdi/content-save
      children
        - label 解压整个归档
          desc 调用 tarExtract 提取所有文件
        - label 读取 manifest.files
          desc 获取实际备份成功的文件列表
        - label 按 manifest 复制文件
          desc 只还原 success: true 的文件
        - label 显示跳过的文件
          desc 报告 success: false 的文件

预览函数和执行函数

关键在于提供两个功能相似但副作用不同的函数:

// 预览函数:只读取 manifest,不解压不修改文件
export function getRestorePreview(backupPath: string): RestorePreviewItem[] {
  // 只提取 manifest.json,不解压整个归档
  const manifestContent = tarExtractManifest(backupPath);
  if (!manifestContent) {
    throw new Error("无法读取备份 manifest");
  }

  const manifest = JSON.parse(manifestContent);
  const previewItems: RestorePreviewItem[] = [];

  // 基于 manifest.files 生成预览
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    if (!success) continue; // 跳过备份失败的文件

    const projectPath = RESTORE_MAP[backupPath];
    if (!projectPath) continue;

    // 从归档中统计文件数量(不解压)
    const files = tarList(backupPath);
    const matchingFiles = files.filter(
      (f) => f === backupPath || f.startsWith(`${backupPath}/`)
    );
    const fileCount = matchingFiles.length;

    previewItems.push({
      path: projectPath,
      fileCount: fileCount || 1,
      backupPath,
    });
  }

  return previewItems;
}

// 执行函数:实际解压并复制文件
export function restoreBackup(backupPath: string): RestoreResult {
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
  tarExtract(backupPath, tempDir); // 实际解压

  // 读取 manifest 驱动还原
  const manifest = JSON.parse(
    fs.readFileSync(path.join(tempDir, "manifest.json"), "utf-8")
  );

  const restored: string[] = [];
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    if (!success) continue;
    const projectPath = RESTORE_MAP[backupPath];
    // ... 实际复制文件
    fs.cpSync(srcPath, destPath, { recursive: true });
    restored.push(projectPath);
  }

  return { restored, skipped: [], backupType: manifest.type };
}

两个函数的核心区别:

  • 预览:调用 tarExtractManifest() 只提取 manifest,再用 tarList() 统计文件数量
  • 执行:调用 tarExtract() 解压整个归档,基于 manifest.files 复制文件

组件层:条件分发

在 React 组件中,根据 dryRun 参数决定调用哪个函数:

interface RestoreAppProps {
  dryRun?: boolean; // 是否为预览模式
  force?: boolean; // 是否跳过确认
}

export function RestoreApp({ dryRun = false, force = false }: RestoreAppProps) {
  const [result, setResult] = useState<{
    items: RestorePreviewItem[] | string[];
    backupType?: string;
    skipped?: string[];
  }>();

  // 预览模式:只读取 manifest
  const runDryRun = useCallback(() => {
    const previewItems = getRestorePreview(selectedBackup);
    setResult({ items: previewItems });
    setStatus("done");
  }, [selectedBackup]);

  // 实际还原:基于 manifest 执行还原
  const runRestore = useCallback(() => {
    setStatus("restoring");
    const { restored, skipped, backupType } = restoreBackup(selectedBackup);
    setResult({ items: restored, backupType, skipped });
    setStatus("done");
  }, [selectedBackup]);

  // 确认时根据模式分发
  function handleConfirm() {
    if (dryRun) {
      runDryRun();
    } else {
      runRestore();
    }
  }
}

关键设计:

  • 统一数据结构result 可以容纳预览和执行两种结果
  • 类型区分:预览返回 RestorePreviewItem[](含 fileCount),执行返回 string[]
  • 额外信息:执行模式返回 backupTypeskipped,用于显示完整信息

UI 层:差异化展示

预览模式和实际执行模式在 UI 上有明确区分:

{
  /* 确认提示:显示备份类型和文件数量 */
}
<Text color="yellow">
  {dryRun ? "[预览模式] " : ""}
  确认还原 {result?.backupType} 备份? 此操作将覆盖现有文件
</Text>;

{
  /* 完成状态:根据模式显示不同标题 */
}
<Text bold color="green">
  {dryRun ? "预览模式" : "还原完成"}
</Text>;

{
  /* 结果展示:预览模式显示文件数量统计 */
}
{
  result?.items.map((item) => {
    const isPreviewItem = typeof item !== "string";
    const filePath = isPreviewItem ? item.path : item;
    const fileCount = isPreviewItem ? item.fileCount : 0;
    return (
      <Text key={filePath}>
        <Text color="green">{"  "}+ </Text>
        <Text>{filePath}</Text>
        {/* 预览模式额外显示文件数量 */}
        {isPreviewItem && fileCount > 1 && (
          <Text dimColor> ({fileCount} 文件)</Text>
        )}
      </Text>
    );
  });
}

{
  /* 统计文案:使用 "将" vs "已" 区分 */
}
<Text>
  {dryRun ? "将" : "已"}还原: <Text color="green">{result?.items.length}</Text>{" "}
  项
</Text>;

{
  /* 显示跳过的文件(仅实际执行模式) */
}
{
  !dryRun && result?.skipped && result.skipped.length > 0 && (
    <Box flexDirection="column" marginTop={1}>
      <Text color="yellow">跳过的文件:</Text>
      {result.skipped.map((file) => (
        <Text key={file} dimColor>
          {"  "}- {file}
        </Text>
      ))}
    </Box>
  );
}

{
  /* 预览模式特有提示 */
}
{
  dryRun && <Text color="yellow">这是预览模式,没有文件被修改</Text>;
}

{
  /* 实际执行模式:显示后续步骤 */
}
{
  !dryRun && (
    <Box flexDirection="column" marginTop={1}>
      <Text dimColor>后续步骤:</Text>
      <Text dimColor>{"  "}1. pnpm install # 安装依赖</Text>
      <Text dimColor>{"  "}2. pnpm build # 构建项目</Text>
    </Box>
  );
}

命令行使用

# 预览模式:查看将要还原的内容
pnpm koharu restore --dry-run

# 实际执行
pnpm koharu restore

# 跳过确认直接执行
pnpm koharu restore --force

# 还原最新备份(预览)
pnpm koharu restore --latest --dry-run

输出对比

预览模式输出(Full 备份)

备份文件: backup-2026-01-10-12-30-00-full.tar.gz
备份类型: full
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00

[预览模式] 确认还原 full 备份? 此操作将覆盖现有文件 (Y/n)

预览模式
  + src/content/blog (42 文件)
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img (128 文件)
  + src/assets/lqips.json
  + src/assets/similarities.json
  + src/assets/summaries.json

将还原: 8 项
这是预览模式,没有文件被修改

预览模式输出(Basic 备份)

备份文件: backup-2026-01-10-12-30-00-basic.tar.gz
备份类型: basic
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00

[预览模式] 确认还原 basic 备份? 此操作将覆盖现有文件 (Y/n)

预览模式
  + src/content/blog (42 文件)
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img/avatar.webp

将还原: 5 项
这是预览模式,没有文件被修改

实际执行输出(含跳过的文件)

还原完成
  + src/content/blog
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img

跳过的文件:
  - src/assets/lqips.json (备份时不存在)

已还原: 5 项
后续步骤:
  1. pnpm install # 安装依赖
  2. pnpm build # 构建项目
  3. pnpm dev # 启动开发服务器

写在最后

能看到这里,那很厉害了,觉得还挺喜欢的话,欢迎给我一个 star 呢~

github.com/cosZone/ast…

自认为这次实现的这个 CLI 对于我自己的需求来说,相当好用,只恨没有早一些实践,如果你看到这篇文章,可以放心大胆的去构建。

相关链接如下

React Ink

Git 同步 Fork

状态机与 useReducer

❌