【Bug已解决】Codex app upgrade loses Superpowers plugin installed state 解决方案

原始报错线索:Codex app upgrade loses Superpowers plugin installed state(应用升级之后,原本装好的 Superpowers 插件状态丢失了,升级完插件像是没装过)。


一、现象长什么样

用户装好了一个插件(如 Superpowers),用得好好的;某天应用提示升级,点完升级:

  1. 插件不见了,功能消失;
  2. 设置里「已安装插件」列表空了;
  3. 重装插件又能用,但升级又会再丢;
  4. 排查发现:状态文件要么被新版本覆盖,要么存在「应用包内部目录」(升级即清)。 根因是插件状态存错了地方 / 升级流程没做状态迁移,更新把用户状态一并抹掉了。

二、背景:状态该存在哪

应用有两类存储位置,绝不能混:

位置 用途 升级时
应用包内(bundle / Program Files / /usr/lib) 程序代码、默认资源 会被整体替换
用户数据目录(AppData / Library/Application Support / .config / .local/share) 用户状态、插件、配置 应保留
插件安装状态是用户状态,必须存到用户数据目录,而不是应用包内。应用升级只替换包内文件,用户目录不动,状态才保得住。

三、为什么升级后丢失:根因

3.1 状态存进应用包目录

插件状态写在 Applications/App.app/Contents/...C:\Program Files\App\plugins.json,升级整体覆盖 → 没了。

3.2 升级流程不迁移

新版本换了状态文件格式/路径,但升级程序没把旧状态搬到新位置,直接用了空的新文件。

3.3 状态无版本号

状态文件没记录「格式版本」,新版本读到旧格式要么忽略要么覆盖,无法识别需迁移。

3.4 原子性缺失

升级中途崩溃,状态文件写了半截 → 损坏 → 应用当「无状态」处理。

四、最小可运行复现(状态存错位置被覆盖)

下面演示「状态写在会被覆盖的目录」为何升级即丢:

import os, json
# 错误:把状态写进『应用包内目录』,升级会整体替换
APP_BUNDLE_DIR = "/Applications/App.app/Contents/Resources/state"
def save_plugin_state_bad(installed):
    os.makedirs(APP_BUNDLE_DIR, exist_ok=True)
    with open(os.path.join(APP_BUNDLE_DIR, "plugins.json"), "w") as f:
        json.dump({"installed": installed}, f)
def simulate_upgrade():
    # 升级:整个包目录被新版本覆盖
    import shutil
    shutil.rmtree(APP_BUNDLE_DIR, ignore_errors=True)
    # 新版本包里没有用户旧状态 -> 丢失
if __name__ == "__main__":
    save_plugin_state_bad(["Superpowers"])
    simulate_upgrade()
    path = os.path.join(APP_BUNDLE_DIR, "plugins.json")
    print("升级后状态还在?", os.path.exists(path))   # False -> 丢失

状态写在包内,升级一覆盖就没了。

五、解决方案一:状态存用户数据目录(不被升级碰)

import os, sys, json
def user_state_dir(app_name="App"):
    """用户级状态目录:升级只换包,不碰这里。"""
    if sys.platform == "darwin":
        base = os.path.expanduser("~/Library/Application Support")
    elif sys.platform == "win32":
        base = os.environ.get("APPDATA")
    else:
        base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
    p = os.path.join(base, app_name)
    os.makedirs(p, exist_ok=True)
    return p
def save_plugin_state(installed):
    d = user_state_dir()
    with open(os.path.join(d, "plugins.json"), "w") as f:
        json.dump({"version": 1, "installed": installed}, f)
def load_plugin_state():
    d = user_state_dir()
    p = os.path.join(d, "plugins.json")
    if not os.path.exists(p):
        return {"version": 1, "installed": []}
    with open(p) as f:
        return json.load(f)
if __name__ == "__main__":
    save_plugin_state(["Superpowers"])
    # 模拟升级:只换包目录,用户目录不动
    print("升级后状态:", load_plugin_state())   # 仍在 ✅

状态进用户目录,升级整体替换包时它安然无恙。

六、解决方案二:状态带版本号 + 升级迁移

状态文件记录 version,升级时若版本旧则迁移到新格式,绝不丢弃:

def migrate_state(old):
    """把旧版本状态迁移到当前版本,保留已安装插件。"""
    current = {"version": 2, "installed": [], "enabled": {}}
    # 旧版只有 installed 列表,迁移时保留并补 enabled 默认
    current["installed"] = old.get("installed", [])
    for name in current["installed"]:
        current["enabled"][name] = True
    return current
def load_with_migration():
    state = load_plugin_state()
    if state.get("version", 1) < 2:
        state = migrate_state(state)
        # 迁移后写回(幂等)
        d = user_state_dir()
        with open(os.path.join(d, "plugins.json"), "w") as f:
            json.dump(state, f)
    return state
if __name__ == "__main__":
    # 假设旧状态是 version 1
    save_plugin_state(["Superpowers"])   # 写入 version 1
    # 升级后加载:自动迁移到 version 2,插件保留
    print(load_with_migration())

version + migrate 保证升级不丢数据,旧格式平滑过渡(解决 3.2/3.3)。

七、解决方案三:原子写 + 升级保留验证

状态写入用临时文件 + 原子替换,且升级完成后做「状态保留校验」:

import os
def atomic_save(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        json.dump(data, f)
    os.replace(tmp, path)     # 原子,避免半截文件
def verify_after_upgrade(expected_installed):
    """升级后检查状态是否保留,缺失则告警/从备份恢复。"""
    state = load_plugin_state()
    missing = [p for p in expected_installed if p not in state["installed"]]
    if missing:
        print(f"[升级] 警告:以下插件状态疑似丢失: {missing}")
        return False
    return True
if __name__ == "__main__":
    save_plugin_state(["Superpowers"])
    atomic_save(os.path.join(user_state_dir(), "plugins.json"),
                load_plugin_state())
    verify_after_upgrade(["Superpowers"])   # True

原子写防损坏,升级后校验防静默丢失(解决 3.4)。

八、跨平台与发布要点

  • 绝不写包内:任何用户状态都走用户数据目录;
  • 备份:升级前备份状态文件,失败可回滚;
  • 多版本共存:若支持多版本并行,状态按版本或向前兼容设计;
  • 权限:用户目录权限要对,别因 ACL 写不进;
  • 可观测:升级日志打印「保留/迁移了哪些插件」。

九、排查清单

「升级后丢插件状态」,按下面排查:

  1. 状态存哪了?是否在应用包内(升级会覆盖)(第五节);
  2. 升级是否迁移状态?旧格式是否被搬到新位置(第六节);
  3. 状态有版本号吗?新版本能否识别旧格式(第六节);
  4. 写入原子吗?半截文件会损坏(第七节);
  5. 升级后校验吗?丢失能否告警(第七节);
  6. 有备份吗?升级前是否备份可回滚;
  7. 权限够吗?用户目录是否写不进;
  8. 日志是否记录保留/迁移

十、小结

「应用升级后丢失插件安装状态」的根因是插件状态存进了会被升级覆盖的位置,且升级流程不做状态迁移。通用修复:

  1. 存对地方:状态进用户数据目录(AppData / Application Support / .config),升级只换包不碰它;
  2. 带版本 + 迁移:状态文件记 version,升级时旧格式自动迁移保留(第六节);
  3. 原子写 + 校验:临时文件 + os.replace 防损坏,升级后校验保留(第七节);
  4. 备份可回滚:升级前备份状态,失败可恢复。 一句话:用户状态永远不能住在「应用包」里,它必须落在升级碰不到的用户目录,且每次升级都要把旧状态迁移到新格式而非丢弃。把状态当成「比代码更持久的东西」来对待,升级就再也不会让用户「装好的插件一夜消失」——这与第 116 篇路径规范、第 123 篇版本锁定、第 130 篇回退,共同体现「用户状态必须持久、可迁移、不被升级破坏」。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐