get-shit-done 安装器迁移层:从变更集发布说明到全运行时安全升级的实现解析
导读
本文围绕 get-shit-done(GSD)仓库中变更集 .changeset/vivid-foxes-romp.md 记录的发布特性展开:将"安装器迁移"(installer migrations)接入 Claude Code、Codex、Cursor、Gemini、OpenCode 等 15 个支持运行时的常规安装/更新流程,并落地六项关键能力——基线扫描(baseline scanning)、折叠式动作报告(collapsed action reporting)、受阻动作保护(blocked-action protection)、事务式回滚(transactional rollback)、安装状态持久化(install-state persistence)与锁保护执行(lock-guarded execution)。读完本文,你将掌握这套迁移层的完整设计目标、迁移记录与动作契约、plan/apply/rollback 执行流水线、安装入口接线方式,以及如何为退役一个安装产物编写合规的迁移与配套测试。
为什么需要一个显式的迁移层
GSD 安装器在此之前其实已经具备多项升级行为:替换由 GSD 托管的命令、技能、Agent、Hook 与引擎文件;在替换前备份本地改动过的托管文件;保留已知的用户自持产物;清理旧 Hook 文件与 Hook 注册项;改写各运行时专有的配置格式;以及部分回滚失败的 Codex 安装。
问题在于这些行为散落在各个安装分支(install branches)中。对孤立的缺陷修复而言这足够灵活,但一旦涉及特性退役就变得危险:未来某个改动可能把文件从发布包中移除,却在已安装目录中留下陈旧的旧副本;或者因为某个文件恰好位于 GSD 托管目录下,就被误当成 GSD 产物而删除用户自建文件。安装器迁移层就是为了让升级行为显式化、可评审、可重复而引入的统一机制(架构文档)。
六个设计目标
- 默认保护用户数据;
- 特性退役时移除陈旧的 GSD 托管文件;
- 破坏性动作在运行前必须可见;
- 记录已发生的动作,避免后续安装重复执行同一迁移;
- 即使各运行时的具体文件不同,也给予一致的安全模型;
- 让迁移编写足够小,使贡献者愿意使用迁移而非再写一段一次性清理代码。
非目标
它不是通用包管理器,不是数据库迁移系统,不会自动推断每一种历史安装布局,不删除任意用户文件,也不会一步取代现有安装变换逻辑。
变更集发布内容速览
变更集记录为 type: Added、关联 PR 3402,正文精确概括了本特性的交付范围:
将安装器迁移接入每个受支持运行时的常规安装流程,在包物化(package materialization)之前完成:基线扫描、折叠式动作报告、受阻动作保护、事务式回滚、安装状态持久化与锁保护执行。
逐词拆解即可得到正文的核心骨架,本文以下各节分别对应其中一项或多项能力展开说明。
核心术语与文件分类模型
在设计文档中定义了五组关键术语,是理解迁移安全边界的前提:
- 托管文件(Managed file):由 GSD 安装并记录在安装清单(install manifest)中的文件。未改动时可自动替换;被本地改动过则必须备份或合并。
- 用户自持文件(User-owned file):由用户工作流或用户直接创建维护的文件。即使位于 GSD 目录下也绝不能因位置而删除。
- 未知文件(Unknown file):位于安装根目录下、既不在清单中也没有被归类为用户自持的文件。默认保留,除非迁移通过专门检测器证明其为陈旧 GSD 产物。
- 迁移(Migration):一个版本化的变更集,可检查当前安装、产出计划(plan),并在安全检查通过后应用计划。
- 计划(Plan):一系列拟议的文件系统与配置动作的清单,可安全展示给用户,描述"将要发生什么、为什么",不触碰磁盘。
实现中的文件分类函数位于 get-shit-done/bin/lib/installer-migrations.cjs,classifyArtifact 依据清单中记录的哈希把某个路径划分为五种分类之一:managed-pristine(托管且哈希一致)、managed-modified(托管但被本地改动)、managed-missing(清单在录但文件缺失)、unknown(不在清单)、missing。计划的生成与动作保护都建立在这套分类之上。
状态持久化:File Manifest 与 Install State
File Manifest(既有基线)
现有清单仍是所有权基线,记录已安装的 GSD 版本、安装模式以及发行版托管文件的哈希。其不变式非常严格:
- 发行版托管文件必须在清单中跟踪;
- 用户自持文件必须被保留且不纳入清单哈希;
- 同一路径不能同时属于两类。
Install State(新增状态记录)
安装器在清单旁写入 gsd-install-state.json(实现常量见 installer-migrations.cjs),设计文档给出的结构如下:
{
"schema": 1,
"runtime": "codex",
"scope": "global",
"installed_version": "1.50.0",
"install_mode": "full",
"applied_migrations": [
{
"id": "2026-05-11-codex-hooks-layout",
"package_version": "1.50.0",
"checksum": "sha256:...",
"applied_at": "2026-05-11T00:00:00.000Z"
}
]
}
实现中的写入做了严格原子写:先把内容写入 gsd-install-state.json.tmp-<pid>-<ts>,再通过 rename 落盘,绝不允许半写状态残留(源码注释明确要求绕过 platformWriteSync,因为它在 rename 失败时回退为直接写,会破坏该不变式)。checksum 由迁移定义本身计算(sha256: 前缀)。若某个已应用迁移的校验和发生变化,安装器必须告警并拒绝静默重跑;修复方向的新增逻辑应使用新的迁移 id 而非改动旧记录(assertAppliedMigrationChecksums 会直接抛错提示 "create a new fix-forward migration id")。
迁移记录契约(Migration Record)
每个迁移导出一份普通记录加纯规划逻辑。必填字段:
module.exports = {
id: '2026-05-11-runtime-layout-example',
title: 'Move legacy commands into runtime skills',
description: 'Move legacy runtime command files into the generated skill layout.',
introducedIn: '1.50.0',
runtimes: ['claude', 'codex', 'gemini'],
scopes: ['global', 'local'],
destructive: true,
plan(ctx) {
return [];
}
};
作者守卫模块会拒绝缺少 id、title、description、introducedIn、scopes、destructive 或 plan 的记录。runtimes 是唯一可选的字段——它仅在迁移刻意对每个运行时共享时省略;scopes 必须始终显式,防止作者无意中扩大 local/global 行为。从源码看,installer-migrations.cjs 中 discoverInstallerMigrations 会扫描 installer-migrations 目录下按文件名排序的所有 .cjs 文件并逐条校验记录。
plan(ctx) 与辅助谓词
plan(ctx) 接收一个包含 runtime、scope、目标目录、上一份清单、安装状态、包清单与文件系统助手的安装上下文,返回动作列表。它绝不能改磁盘。迁移可使用的谓词包括:
isManaged(relPath)isUserOwned(relPath)hashMatchesManifest(relPath)exists(relPath)readJson(relPath)readToml(relPath)
动作类型:迁移产出的最小集合
迁移只产出少量动作类型,执行器统一负责变更、备份、回滚与报告:
| 动作类型 | 语义与适用场景 |
|---|---|
remove-managed |
仅当路径确证为 GSD 托管且与上一份清单哈希一致时删除;必须携带 ownershipEvidence 说明所有权依据。适用于退役 Hook、旧生成 Agent、废弃命令文件、陈旧运行时产物。 |
backup-and-remove |
托管文件与清单不一致时先备份再删除,用户可查看报告与备份。适用于退役"用户可能打过补丁"的托管文件。 |
move-managed |
将托管路径移动到新的托管路径;若源文件被本地改动则升级为 backup-and-move 或冲突。适用于命令目录迁入技能布局这类布局迁移。 |
rewrite-config |
通过解析器或结构化辅助改写结构化配置文件(首期实现 rewrite-json);字符串替换只允许用于窄范围 marker 块且需覆盖行尾与顺序变体的测试。适用于运行时配置、Hook 注册、特性开关、生成式 Agent 注册块。每个 rewrite-json 动作都必须带 ownershipEvidence 与引用运行时配置契约注册表的 runtimeContract。 |
preserve-user |
声明路径为用户自持、必须在目录整体替换中存活的动作;dry-run 中仅信息性展示,apply 时在所有权已知时转为透传/恢复。适用于 profile、偏好、手写指令等。 |
record-baseline |
首次基线扫描中把清单托管文件记入基线而不改动它。仅首次基线扫描器可用。 |
baseline-preserve-user |
记录已知安装面下发现的用户自持或未知文件,不改动它;未知文件默认落到此动作。仅首次基线扫描器可用。 |
prompt-user |
中止非交互式破坏性迁移,在交互模式询问(preserve / back up / remove / move,默认 preserve)。适用于分类歧义且猜测可能丢数据的情形。 |
计划阶段内置两层保护逻辑(源码见 planInstallerMigrations):当迁移请求 remove-managed 但文件分类为 managed-modified 时自动升级为 backup-and-remove;分类为 unknown 时自动降级为 preserve-user;prompt-user 一律进入受阻集合;分类为 unknown 且动作类型不属于 rewrite-json/record-baseline/baseline-preserve-user 的也会进入受阻集合。
事务式执行流程:plan → apply → journal → rollback
安装器在物化新包内容之前运行迁移。设计文档给出了完整流程:
- 构建安装上下文;
- 读取上一份清单与安装状态;
- 为可能触碰的路径构建运行前快照;
- 按 runtime、scope 与已应用状态发现待执行迁移;
- 向每个待执行迁移询问计划;
- 合并并校验计划;
- 以 dry-run 形式打印计划;
- 应用安全的非交互动作;
- 对歧义动作提示或停止;
- 写入新包内容;
- 写入新清单与安装状态;
- 报告备份、保留文件、移除的陈旧文件与跳过的动作。
在 installer-migrations.cjs 中,runInstallerMigrations 是其入口:先 acquireInstallMigrationLock 取锁;无动作时调用 markPendingMigrationsApplied 直接落安装状态;存在受阻动作时返回(不执行任何 apply);否则调用 applyInstallerMigrationPlan。apply 期间:
- 在
gsd-migration-journal/<runId>.json记录运行日志(journal),每条动作附状态:removed、rewritten、recorded、preserved、missing等; - 在
gsd-migration-journal/<runId>-rollback/保存每个被触碰文件的原字节快照; backup-and-remove额外在<runId>-backups/保留可检查的备份;- 全部动作完成后才原子写入新的 install-state;
- 任何一步失败:按 journal 逆序把原字节复制回原位(绝不删除当前安装运行未创建或未修改的文件),恢复先前的 install-state 字节,最后清理 journal 目录;回滚是尽力而为(best-effort),但不完整时必须大声失败(抛出含
rollbackFailures的migration rollback incomplete错误)。
锁保护执行
acquireInstallMigrationLock 实现 gsd-install-migration.lock 的排他互斥,默认超时 30 秒。锁文件内写入持有者 pid 与获取时间;遇到 EEXIST 时先读锁内 pid 并用 process.kill(pid, 0) 探测存活状态,只有确认持有进程已死(ESRCH)或就是本进程时才回收陈旧锁并重试,否则等待到超时后抛出 "installer migration lock is held"。Windows 下释放锁用 unlinkSync 而非吞掉 EPERM 的 rmSync,保证释放失败会浮出表面(对应 tests/bug-3670-cursor-local-install-migration-lock.test.cjs 覆盖的回归场景)。
接入常规安装入口(变更集核心交付)
变更集最核心的一句话是 "for every supported runtime" 与 "before package materialization"。Phase 4 集成把上面这套 runner 接入常规 install/update 入口,覆盖全部 15 个运行时:Claude Code、Antigravity、Augment、Cline、CodeBuddy、Codex、Copilot、Cursor、Gemini、Hermes Agent、Kilo、OpenCode、Qwen Code、Trae 与 Windsurf。
从 bin/install.js 的安装函数可见其接线方式:
- 声明
installerMigrationResult状态并注册回滚闭包rollbackInstallerMigrations(变更前文件已备份、后续 materialization 失败时回滚迁移结果,见 L8014 附近); - 安装路径上调用
runInstallerMigrations({ configDir: targetDir })(同时传递baselineScan: true之类参数,见 L8033 / L8213 两处调用); - 打印
reportInstallerMigrationResult的折叠式报告行(L7812 起); - 非 TTY 场景(典型如 Claude Code 的
/gsd-update)先经resolveInstallerMigrationPromptsForNonTty解析受阻动作(L8241); - 最后
assertInstallerMigrationsUnblocked断言无残留受阻项(L8266),受阻时在写入任何新包文件前直接失败。
也就是说,迁移运行先于包物化;安装状态只在包物化与收尾成功后才持久化;runner 返回受阻动作时安装器在落新文件前失败——这正是变更集所述 "install-state persistence" 与 "blocked-action protection" 在入口处的体现。
折叠式动作报告(collapsed action reporting)
"Collapsed" 针对的是首次基线扫描可能产生海量逐文件动作的问题。summarizeInstallerMigrationResult(installer-migration-report.cjs)把 record-baseline 与 baseline-preserve-user 分别折叠为一行汇总:"N managed baseline files(recorded)" 与 "N user baseline files(preserved)",其余真实动作保持逐文件行。这样首次安装不会被数百行基线噪音淹没,同时升级类动作依旧可见。reportInstallerMigrationResult 最终以 ✓ Installer migrations 头逐行输出 label、relPath 与 reason。
Dry Run:同一套规划器
迁移 runner 支持 dry-run 模式:打印计划后不做任何改动退出。输出按风险分组:
- will preserve
- will replace unchanged managed files
- will remove stale managed files
- will back up locally modified files
- needs user choice
- blocked
关键不变式:dry-run 与 apply 共用同一套规划器,不允许存在独立的"仅预览"代码路径。
受阻动作的非交互解析与保护
无 TTY 的自动更新无法逐文件交互询问。resolveInstallerMigrationPromptsForNonTty 按既定优先级消解受阻动作:
- 操作员覆盖:设置环境变量
GSD_INSTALLER_MIGRATION_RESOLVE=keep|remove,对所有支持该选项的动作生效; - 非 TTY 安全默认分类(
classifyPromptUserAction):- 陈旧 SDK 构建产物(
get-shit-done/sdk/{dist,src}/下路径,每次安装都会重新生成,删除无损)→remove; - 面向用户的技能锚点(
skills/gsd-*/SKILL.md)→keep,保护用户内容; - 发行包内白名单内置 Hook(
hooks/下以 BUNDLED_GSD_HOOK_FILES 白名单为准的 13 个文件)→remove,因为首装基线后安装器会立即写入新版;
- 陈旧 SDK 构建产物(
- 其余一律走硬断言:
assertInstallerMigrationsUnblocked抛出分组错误,按 reason 汇总文件数、给出最多 3 个示例路径与可用选项,并提示通过GSD_INSTALLER_MIGRATION_RESOLVE或 TTY 交互解决。
白名单为何必要:若不限制为精确文件名集合,^hooks/gsd-[^/]+\.(js|sh|...)$ 这类形状正则同样会命中用户自写自定义 Hook 与旧版退役内置 Hook,自动删除即静默数据丢失。相关回归覆盖见 tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs(双向断言:白名单缺文件或发行多了未白名单文件都会让 CI 失败)。
首次基线迁移:旧安装的逃生舱
首条迁移记录 2026-05-11-first-time-baseline-scan(实现在 000-first-time-baseline.cjs)不做任何破坏性修复,只分类既有安装。它按运行时已知安装面(如 claude 的 get-shit-done/commands/gsd/skills/agents/hooks/settings.json;codex 另含 config.toml/hooks.json)递归扫描,跳过内部顶层文件后逐项分类:
- manifest 托管且 pristine/modified →
record-baseline; - 已知安装器生成的 Agent 文件(与仓库
agents/gsd-*.md名称集合比对)→record-baseline; - 已知用户自持路径或非
gsd-前缀的 skills/agents 子项 →baseline-preserve-user; - 长得像 GSD 但清单无法证明的陈旧文件 →
prompt-user(keep/remove); - 其余未知文件 →
baseline-preserve-user。
runner 仅在调用方传 baselineScan: true 时激活该记录;否则该记录不产出动作,常规迁移发现的意外文件继续走保护逻辑。这为早于完整迁移跟踪的旧安装提供了可评审的重分配/清理计划,而无需安装器完美推断每一次历史版本过渡。
后续已内置的两条显式迁移可作范例:001-legacy-orphan-files.cjs(把孤立的旧 Hook/文件清理收编为首个显式迁移)与 002-codex-legacy-hooks-json.cjs(Codex hooks.json 的结构化清理——GSD 只证明个别生成 Hook 命令的所有权,不拥有整个文件,故走 rewrite-json 且仅改写自有部分)。
安全策略
- 所有权:绝不删除未知文件。未知文件默认保留,除非迁移内含能证明其为陈旧 GSD 产物的专门检测器。
- 改动检测:路径在上一份清单中时——哈希一致 = 未改动的托管文件;哈希不一致 = 本地改动过的托管文件;缺失 = 用户已自行删除、应保持删除(除非迁移明确需要重建)。
- 用户自持产物:用户自持产物只定义一次,被保留逻辑与清单写入逻辑共同消费;新增用户自持产物必须附带回归测试,证明其在重装后被保留且不出现在清单中。
- 配置文件:运行时配置是混合所有权。GSD 可能拥有 marker 块、生成式 Agent 区块或 Hook 条目,但除非文件本就是 GSD 专用文件,否则不拥有整个文件。配置迁移只删除或改写自有部分。
运行时配置契约注册表
设计文档维护了一张覆盖全部运行时的配置契约注册表(docs/installer-migrations.md 中的 Runtime Configuration Contract Registry),作为触碰宿主运行时配置的迁移的唯一权威依据。每行记录五个维度:What(GSD 安装的调用面/Agent/技能/规则/Hook/配置面)、Where(安装器瞄准的 global/local 根目录)、When(install/upgrade/uninstall/迁移触点)、Who(周边用户配置的所有权边界)、Why(上游加载器契约或当前 GSD 兼容垫片)。要点包括:
- Claude Code:GSD 拥有生成的技能、本地命令、
gsd-*Agent、Hook 文件与settings.json中的 GSD hook/statusLine 条目(文档核验时间 2026-05-11); - Codex:GSD 拥有生成技能、生成的 Agent TOML、
agents.gsd-*配置区段、GSD 添加的[features].hooks(canonical 键;遗留别名codex_hooks被识别并前向迁移,#3566)与 GSD Hook 条目,兼容哨兵为 Codex 0.130.0 的 features.hooks 键; - Gemini CLI:GSD 拥有生成的 TOML 命令/Agent/Hook 与 GSD settings 条目;本地命令副本在全局 GSD 命令已存在时可跳过;
- 若干运行时(Antigravity、Windsurf、Trae 等)上游文档不完整或受限,注册表将这些行标记为 source-limited / 高风险,重写此类迁移需新主源或带测试的安装器级探针;
- 文档还要求:配置改写优先用结构化解析器(JSON/JSONC/TOML/YAML);marker 块重写需要行尾与顺序变体测试;上游文档或 CLI 模式变化时更新快照日期、更新
docs/ARCHITECTURE.md并为新形态补测试后再改迁移行为。
为迁移作者准备的编写工作流
当 PR 要移除或移动安装产物时,PR 必须包含:
- 一条迁移记录;
- dry-run 计划输出的测试;
- apply 行为的测试;
- 本地改动托管文件的测试;
- 变更路径附近用户自持文件的测试;
- 若影响用户可见的安装行为,更新发布说明。
作者必须在迁移文件中回答:被退役的是哪种旧产物或配置形态?如何证明其为 GSD 所有?用户改过会怎样?缺失会怎样?影响哪些 runtime 与 scope?非交互安装中该动作是否安全?
测试矩阵与实现顺序
迁移 runner 的任何改动都应覆盖:无历史状态的全新安装、清单一致的重装、有待执行迁移的升级、本地改动过的托管文件、GSD 目录下的未知文件、被整体抹掉目录下的用户自持文件、apply 失败后的回滚、适用的 global/local 安装 scope、路径序列化时的 Windows 分隔符、以及配置改写时的 CRLF 输入。仓库中对应实现于 tests/installer-migrations.test.cjs、tests/installer-migration-report.test.cjs 与 tests/installer-migration-install-integration.test.cjs(后者即变更集所述的 all-runtime 安装矩阵,逐一演练安全托管清理与受阻用户选择产物)。
建议实现顺序与迁移层在仓库中的演化一致:先抽离围绕清单与用户自持列表的所有权助手 → 安装状态读写助手 → 迁移发现与校验和 → 纯规划器 dry-run → 带日志的 executor → 把孤立 Hook/文件清理迁入首条显式迁移 → 把一次结构化配置改写迁入 runner → 基线分类器 → 之后凡移动/改名/退役产物的新安装 PR 必须配迁移。该顺序让首版保持小巧:既有安装器继续物化文件,而迁移 runner 接管清理、分类与可评审的破坏性变更。
设计借鉴(Prior Art)
该设计明确借鉴了既有升级系统:Flyway 的版本化迁移(按校验和跟踪的有序、一次执行)、Flyway 的 dry-run 预览;Liquibase 的 changeset 与前置条件(受当前系统状态门控的声明式变更);Debian conffile 策略(保留本地配置并区分包所有权与用户所有权);npm 生命周期脚本作为打包语境参考,但因卸载与升级上下文受限而不足以充当迁移机制。
上述全部内容——从基线扫描、折叠报告、受阻保护、事务回滚、安装状态持久化到锁保护执行——共同构成了变更集 vivid-foxes-romp.md 一句话背后完整的安装器迁移架构,并已通过 bin/install.js 接入全部 15 个运行时的常规安装与更新入口。对贡献者而言,新增迁移的正确起点是阅读 docs/installer-migrations.md 的注册表行,复用已有 executor 而非追加一次性清理块。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00