multi-codex 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ """平台适配:默认路径、创建与识别链接、占用检查。
2
+
3
+ 一期只支持 macOS 与 Linux。二期加 Windows 时,平台差异都在本模块和启动命令模板里扩展,
4
+ 其它模块不直接判断操作系统。
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from typing import List, NamedTuple, Optional, Tuple
12
+
13
+ from .fsutil import expand, is_under
14
+
15
+ # 这些变量在全局设置时会破坏账号隔离,见方案 §3。
16
+ ISOLATION_BREAKING_ENV = ("CODEX_SQLITE_HOME", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN")
17
+
18
+
19
+ def is_supported() -> bool:
20
+ return sys.platform == "darwin" or sys.platform.startswith("linux")
21
+
22
+
23
+ def default_source() -> str:
24
+ return "~/.codex"
25
+
26
+
27
+ def default_root() -> str:
28
+ return "~/.cx"
29
+
30
+
31
+ def default_bin_dir() -> str:
32
+ return "~/.local/bin"
33
+
34
+
35
+ def dir_on_path(directory: str) -> bool:
36
+ """directory 是否在当前 PATH 中。两边都解析软链后比较,`~` 写法与软链写法都算命中。
37
+
38
+ doctor 的 path 检查与 add / migrate-default 之后的提示共用它,两处结论必须一致。
39
+ """
40
+ entries = [os.path.realpath(expand(entry)) for entry in os.environ.get("PATH", "").split(os.pathsep) if entry]
41
+ return os.path.realpath(directory) in entries
42
+
43
+
44
+ def state_dir() -> str:
45
+ """工具状态目录:config.json、lock、migrate-journal.json 所在位置。"""
46
+ xdg = os.environ.get("XDG_CONFIG_HOME")
47
+ base = xdg if xdg and os.path.isabs(xdg) else os.path.expanduser("~/.config")
48
+ return os.path.join(base, "multi-codex")
49
+
50
+
51
+ def create_link(target: str, link_path: str) -> None:
52
+ """在 link_path 创建指向 target 的软链;target 使用绝对路径,避免随工作目录变化。"""
53
+ os.symlink(target, link_path)
54
+
55
+
56
+ class BusyProcess(NamedTuple):
57
+ pid: int
58
+ command: str
59
+ # 占用方式(给人看的):cwd(工作目录)、executable(可执行文件)、mapped(内存映射)或 fd N(打开的文件)
60
+ usage: str
61
+ # 落在源目录下的那个路径,让用户一眼看出是哪个文件被占用
62
+ path: str
63
+
64
+
65
+ def describe_usage(fd: str) -> str:
66
+ """把 lsof 的 fd 字段或 /proc 的条目名翻译成好懂的占用方式;未知值原样返回。"""
67
+ if fd == "txt":
68
+ return "executable"
69
+ if fd == "mem":
70
+ return "mapped"
71
+ digits = fd.rstrip("rwuRWU")
72
+ if digits.isdigit():
73
+ return "fd " + digits
74
+ return fd
75
+
76
+
77
+ class BusyCheckError(Exception):
78
+ """占用检查本身无法完成(找不到检查工具,或 lsof 没有正常输出)。"""
79
+
80
+
81
+ def find_busy_processes(path: str, exclude_pids: Optional[List[int]] = None) -> List[BusyProcess]:
82
+ """列出工作目录、可执行文件或打开的文件落在 path 之下的进程。
83
+
84
+ 只检查顶层数据库文件会漏掉浏览器扩展宿主这类只把工作目录设在其中的进程,
85
+ 所以这里对全部进程逐个判断。结果按“进程名含 codex 的在前”排序。
86
+ """
87
+ root = os.path.realpath(path)
88
+ excluded = set(exclude_pids or [])
89
+ excluded.add(os.getpid())
90
+ if shutil.which("lsof"):
91
+ found = _scan_with_lsof(root, excluded)
92
+ elif sys.platform.startswith("linux") and os.path.isdir("/proc"):
93
+ found = _scan_proc(root, excluded)
94
+ else:
95
+ raise BusyCheckError("neither lsof nor /proc is available")
96
+ found.sort(key=lambda item: ("codex" not in item.command.lower(), item.pid))
97
+ return found
98
+
99
+
100
+ def _scan_with_lsof(root: str, excluded: set) -> List[BusyProcess]:
101
+ # -F pcfn:按字段输出 pid、命令、fd、文件名;-n -P 关闭 DNS 与端口名解析,避免卡顿。
102
+ proc = subprocess.Popen(
103
+ ["lsof", "-n", "-P", "-F", "pcfn"],
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.PIPE,
106
+ universal_newlines=True,
107
+ errors="replace",
108
+ )
109
+ stdout, _stderr = proc.communicate()
110
+ excluded = excluded | {proc.pid}
111
+ has_process_record = False
112
+ found = {}
113
+ pid = None
114
+ command = ""
115
+ fd = ""
116
+ for line in stdout.splitlines():
117
+ if not line:
118
+ continue
119
+ field, value = line[0], line[1:]
120
+ if field == "p":
121
+ has_process_record = True
122
+ pid = int(value) if value.isdigit() else None
123
+ command = ""
124
+ fd = ""
125
+ elif field == "c":
126
+ command = value
127
+ elif field == "f":
128
+ fd = value
129
+ elif field == "n" and pid is not None and pid not in excluded:
130
+ if value.startswith("/") and is_under(value, root) and pid not in found:
131
+ found[pid] = BusyProcess(pid, command, describe_usage(fd), value)
132
+ # Linux 普通用户运行 lsof 时,常因无权读取其它用户的进程而返回非 0 并输出告警;
133
+ # 只有一个进程记录都没有时才视为检查失败,否则会把正常情况误判为失败。
134
+ if proc.returncode != 0 and not has_process_record:
135
+ raise BusyCheckError("lsof exited with code {} and produced no output".format(proc.returncode))
136
+ return list(found.values())
137
+
138
+
139
+ def _scan_proc(root: str, excluded: set) -> List[BusyProcess]:
140
+ found = []
141
+ for entry in os.listdir("/proc"):
142
+ if not entry.isdigit():
143
+ continue
144
+ pid = int(entry)
145
+ if pid in excluded:
146
+ continue
147
+ base = os.path.join("/proc", entry)
148
+ hit = _proc_usage(base, root)
149
+ if hit:
150
+ found.append(BusyProcess(pid, _proc_command(base), describe_usage(hit[0]), hit[1]))
151
+ return found
152
+
153
+
154
+ def _proc_usage(base: str, root: str) -> Optional[Tuple[str, str]]:
155
+ """返回 (占用方式, 路径);与 lsof 的 fd 字段取值保持一致,交给 describe_usage 统一翻译。"""
156
+ for name, usage in (("cwd", "cwd"), ("exe", "txt")):
157
+ target = _readlink_quiet(os.path.join(base, name))
158
+ if target and is_under(target, root):
159
+ return usage, target
160
+ fd_dir = os.path.join(base, "fd")
161
+ try:
162
+ fds = os.listdir(fd_dir)
163
+ except OSError:
164
+ return None
165
+ for fd in fds:
166
+ target = _readlink_quiet(os.path.join(fd_dir, fd))
167
+ if target and target.startswith("/") and is_under(target, root):
168
+ return fd, target
169
+ return None
170
+
171
+
172
+ def _proc_command(base: str) -> str:
173
+ try:
174
+ with open(os.path.join(base, "comm"), "r", encoding="utf-8", errors="replace") as handle:
175
+ return handle.read().strip()
176
+ except OSError:
177
+ return "?"
178
+
179
+
180
+ def _readlink_quiet(path: str) -> str:
181
+ try:
182
+ return os.readlink(path)
183
+ except OSError:
184
+ return ""
multi_codex/shared.py ADDED
@@ -0,0 +1,127 @@
1
+ """共享资源链接的计划(方案 §5.1.6)。
2
+
3
+ 每个账号可以把 AGENTS.md、skills 等条目软链到同一个共享目录。
4
+ 判断“是不是本工具建的链接”只看 Account.managed_links:链接本身带不了受管标记,
5
+ 而用户很可能早就手工建过指向同一共享目录的链接,这类链接关闭共享时不能删。
6
+ """
7
+
8
+ import os
9
+ from typing import List, Optional
10
+
11
+ from . import platform
12
+ from .actions import CONFLICT, CREATE, DELETE, SKIP, UNCHANGED, UPDATE, Action
13
+ from .config import Account, Config
14
+ from .fsutil import KIND_LINK, KIND_MISSING, entry_kind, expand, same_target
15
+
16
+
17
+ def plan_shared(new: Config, account: Account, account_dir: str,
18
+ old_shared_dir: Optional[str], adopt: bool = False) -> List[Action]:
19
+ """为一个账号生成共享链接动作,并就地更新 account.managed_links。
20
+
21
+ adopt 为真时(`add --adopt`),已经指向对应共享条目、但不在名单里的软链会被写进名单(接管),
22
+ 链接本身不动。只接管“指向正确”的软链:指向别处的软链和真实目录照旧判冲突,
23
+ 以免把用户另有用途的东西交给工具,之后关闭共享时被删掉。
24
+
25
+ old_shared_dir 是变更前的共享目录:修改 shared.dir 后,
26
+ 仍指向旧目录的受管链接需要改指向新目录,而不是被当成“指向别处”的冲突。
27
+ """
28
+ actions: List[Action] = []
29
+ desired = {}
30
+ if account.shared:
31
+ if not new.shared_dir:
32
+ return [Action(CONFLICT, "shared-link", account_dir,
33
+ "shared.dir is not configured; run `multi-codex init --shared-dir DIR`")]
34
+ shared_root = expand(new.shared_dir)
35
+ for item in new.shared_items:
36
+ source = os.path.join(shared_root, item)
37
+ if entry_kind(source) == KIND_MISSING:
38
+ actions.append(Action(SKIP, "shared-link", os.path.join(account_dir, item),
39
+ "not present in shared dir {}".format(shared_root)))
40
+ continue
41
+ desired[item] = source
42
+
43
+ managed = set(account.managed_links)
44
+ old_root = expand(old_shared_dir) if old_shared_dir else None
45
+ kept: List[str] = []
46
+
47
+ for item, source in desired.items():
48
+ link = os.path.join(account_dir, item)
49
+ kind = entry_kind(link)
50
+ if kind == KIND_MISSING:
51
+ actions.append(Action(CREATE, "shared-link", link, "-> " + source,
52
+ _create_link(source, link)))
53
+ kept.append(item)
54
+ elif kind == KIND_LINK and same_target(link, source):
55
+ if item in managed:
56
+ actions.append(Action(UNCHANGED, "shared-link", link))
57
+ kept.append(item)
58
+ elif adopt:
59
+ # 没有文件操作:接管只体现在 config.json 的 managed_links 里。
60
+ actions.append(Action(UPDATE, "shared-link", link, "adopted"))
61
+ kept.append(item)
62
+ else:
63
+ actions.append(Action(UNCHANGED, "shared-link", link))
64
+ elif kind == KIND_LINK and item in managed and _points_to(link, old_root, item):
65
+ actions.append(Action(UPDATE, "shared-link", link, "-> " + source,
66
+ _replace_link(source, link)))
67
+ kept.append(item)
68
+ else:
69
+ reason = "a link to another location" if kind == KIND_LINK else "a real {}".format(
70
+ "directory" if kind == "dir" else "file")
71
+ actions.append(Action(CONFLICT, "shared-link", link,
72
+ "already exists as {}".format(reason)))
73
+
74
+ for item in account.managed_links:
75
+ if item in desired:
76
+ continue
77
+ link = os.path.join(account_dir, item)
78
+ kind = entry_kind(link)
79
+ if kind == KIND_MISSING:
80
+ continue
81
+ if kind == KIND_LINK and (_points_to(link, old_root, item) or
82
+ _points_to(link, expand(new.shared_dir) if new.shared_dir else None, item)):
83
+ actions.append(Action(DELETE, "shared-link", link, "sharing disabled for this item",
84
+ _unlink(link)))
85
+ else:
86
+ # 用户已把它换成了别的东西,不再归本工具管理;从名单里移除,但不动它。
87
+ actions.append(Action(SKIP, "shared-link", link,
88
+ "no longer the link multi-codex created; left untouched"))
89
+
90
+ # 保持与 shared_items 相同的顺序,便于人工阅读 config.json。
91
+ order = {item: index for index, item in enumerate(new.shared_items)}
92
+ account.managed_links = sorted(set(kept), key=lambda item: order.get(item, len(order)))
93
+ return actions
94
+
95
+
96
+ def plan_remove_links(account: Account, account_dir: str, shared_dir: Optional[str]) -> List[Action]:
97
+ """账号被注销时,删除本工具建立、且仍指向共享目录的链接。"""
98
+ actions: List[Action] = []
99
+ root = expand(shared_dir) if shared_dir else None
100
+ for item in account.managed_links:
101
+ link = os.path.join(account_dir, item)
102
+ if entry_kind(link) == KIND_LINK and _points_to(link, root, item):
103
+ actions.append(Action(DELETE, "shared-link", link, "account removed", _unlink(link)))
104
+ return actions
105
+
106
+
107
+ def _points_to(link: str, shared_root: Optional[str], item: str) -> bool:
108
+ return shared_root is not None and same_target(link, os.path.join(shared_root, item))
109
+
110
+
111
+ def _create_link(source: str, link: str):
112
+ def run() -> None:
113
+ platform.create_link(source, link)
114
+ return run
115
+
116
+
117
+ def _replace_link(source: str, link: str):
118
+ def run() -> None:
119
+ os.unlink(link)
120
+ platform.create_link(source, link)
121
+ return run
122
+
123
+
124
+ def _unlink(link: str):
125
+ def run() -> None:
126
+ os.unlink(link)
127
+ return run
multi_codex/switch.py ADDED
@@ -0,0 +1,286 @@
1
+ """切换默认账号(use)与撤销迁移(restore),见 docs/feature/feature-default-switch.md。
2
+
3
+ “默认账号”就是 `~/.codex` 软链指向的账号目录:直接运行的 codex、桌面端和 IDE 扩展都用它。
4
+
5
+ 为什么切换前必须确认没有进程在用旧账号:没设置 CODEX_HOME 的 Codex 进程只记住字面路径 `~/.codex`,
6
+ 之后每次读写都按这个路径重新打开文件(上游 utils/home-dir/src/lib.rs:52-61)。运行中改了软链,
7
+ 它已打开的句柄还在旧账号、新打开的文件却落到新账号;刷新 token 时甚至可能把旧账号的 token
8
+ 写进新账号的 auth.json(上游 login/src/auth/manager.rs:1600-1622)。
9
+
10
+ restore 的进度标记 restore-journal.json:
11
+ - 写于移动任何东西之前,第 3 步注销成功后才删除;
12
+ - 标记存在期间,cli 拦下其它写命令(见 cli._blocked_by_migration),否则 use / migrate-default
13
+ 可能在 restore 做到一半时新建或改动 ~/.codex,让它再也无法完成;
14
+ - 用来区分“restore 做到一半”和“用户手工删掉了账号目录”,两者的文件状态相同。
15
+ 调用方(cli.dispatch)已持有写锁。
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from typing import Optional, Tuple
21
+
22
+ from . import accounts, binding, launcher, migrate, platform
23
+ from .actions import error, info, warn
24
+ from .config import Config
25
+ from .fsutil import KIND_DIR, KIND_LINK, KIND_MISSING, atomic_write, entry_kind, expand
26
+
27
+ STATE_A = "A"
28
+ STATE_A2 = "A'"
29
+ STATE_B = "B"
30
+ STATE_C = "C"
31
+
32
+
33
+ class RestoreJournalError(Exception):
34
+ """restore 标记存在但读不出来;不能猜测进度,交给用户手工检查。"""
35
+
36
+
37
+ def default_link() -> str:
38
+ # default_source() 返回字面量 "~/.codex",必须展开后才能操作。
39
+ return expand(platform.default_source())
40
+
41
+
42
+ def restore_journal_path() -> str:
43
+ return os.path.join(platform.state_dir(), "restore-journal.json")
44
+
45
+
46
+ def load_restore_journal() -> Optional[dict]:
47
+ path = restore_journal_path()
48
+ try:
49
+ with open(path, "r", encoding="utf-8") as handle:
50
+ journal = json.load(handle)
51
+ except FileNotFoundError:
52
+ return None
53
+ except (OSError, ValueError) as exc:
54
+ raise RestoreJournalError("cannot read the restore marker {}: {}".format(path, exc))
55
+ if not isinstance(journal, dict) or any(not isinstance(journal.get(key), str)
56
+ for key in ("name", "source", "target")):
57
+ raise RestoreJournalError("the restore marker {} is incomplete".format(path))
58
+ return journal
59
+
60
+
61
+ def pending_restore_notice() -> Optional[str]:
62
+ journal = load_restore_journal()
63
+ if journal is None:
64
+ return None
65
+ return "an unfinished `restore {0}` exists; rerun `multi-codex restore {0}` to finish it".format(journal["name"])
66
+
67
+
68
+ def _registered_owner(config: Config, real_path: str) -> Optional[str]:
69
+ for name in config.accounts:
70
+ if os.path.realpath(accounts.account_dir(config, name)) == real_path:
71
+ return name
72
+ return None
73
+
74
+
75
+ def describe_default(config: Config) -> Tuple[Optional[str], str]:
76
+ """返回 (默认账号名或 None, 给人看的说明),供 `use`(不带参数)和 `list` 使用。"""
77
+ link = default_link()
78
+ kind = entry_kind(link)
79
+ if kind == KIND_MISSING:
80
+ return None, "(none)"
81
+ if kind == KIND_DIR:
82
+ return None, "(not migrated: ~/.codex is a real directory)"
83
+ if kind == KIND_LINK:
84
+ real = os.path.realpath(link)
85
+ owner = _registered_owner(config, real)
86
+ if owner is not None:
87
+ return owner, owner
88
+ return None, "(unregistered: {})".format(real)
89
+ return None, "(~/.codex is neither a directory nor a link)"
90
+
91
+
92
+ def use_account(config: Config, name: str, skip_process_check: bool, dry_run: bool) -> int:
93
+ account = config.find(name)
94
+ if account is None:
95
+ error(config.not_registered(name), phase="use")
96
+ return accounts.EXIT_ERROR
97
+ target = accounts.account_dir(config, account.name)
98
+ # 不跟随软链:账号目录本身是软链时,~/.codex 会变成“软链指向软链”,与 restore 的前提不一致。
99
+ if entry_kind(target) != KIND_DIR:
100
+ error("account directory is not a real directory", phase="use", path=target)
101
+ return accounts.EXIT_ERROR
102
+ link = default_link()
103
+ kind = entry_kind(link)
104
+ if kind == KIND_LINK:
105
+ current = os.path.realpath(link)
106
+ if current == os.path.realpath(target):
107
+ info("already the default account: {}".format(account.name))
108
+ return accounts.EXIT_OK
109
+ if _registered_owner(config, current) is None:
110
+ error("~/.codex links to {}, which is not a registered account; nothing was changed".format(current),
111
+ phase="use", path=link)
112
+ return accounts.EXIT_CONFLICT
113
+ if skip_process_check:
114
+ warn("--skip-process-check: a running Codex that uses ~/.codex may mix files of the two accounts")
115
+ else:
116
+ # 只查旧账号目录:正在用它的进程会在切换后读写到新账号;查不出占用不代表绝对安全,见方案 §1.1。
117
+ code = migrate._busy_check(current)
118
+ if code:
119
+ return code
120
+ elif kind == KIND_DIR:
121
+ error("~/.codex is a real directory; run `multi-codex migrate-default NAME` first", phase="use", path=link)
122
+ return accounts.EXIT_CONFLICT
123
+ elif kind != KIND_MISSING:
124
+ error("~/.codex is neither a directory nor a link", phase="use", path=link)
125
+ return accounts.EXIT_CONFLICT
126
+
127
+ if dry_run:
128
+ info("(dry-run) would point ~/.codex at {}".format(target))
129
+ return accounts.EXIT_OK
130
+ temp_link = "{}.multi-codex-tmp.{}".format(link, os.getpid())
131
+ try:
132
+ platform.create_link(target, temp_link)
133
+ migrate._test_hook("use-replace")
134
+ # 用 rename 原子替换:任何时刻 ~/.codex 要么指向旧账号、要么指向新账号,不会暂时不存在。
135
+ os.replace(temp_link, link)
136
+ except Exception as exc: # noqa: BLE001 —— 任何失败都要清掉临时软链,~/.codex 保持原样
137
+ if os.path.lexists(temp_link):
138
+ os.unlink(temp_link)
139
+ error("{}: {}".format(type(exc).__name__, exc), phase="use", path=link)
140
+ return accounts.EXIT_ERROR
141
+ info("default account is now {} (~/.codex -> {}); restart the Codex desktop app and IDE extensions".format(
142
+ account.name, target))
143
+ if os.environ.get("CODEX_HOME"):
144
+ warn("CODEX_HOME is set in this shell; running `codex` here ignores ~/.codex")
145
+ return accounts.EXIT_OK
146
+
147
+
148
+ def _restore_state(config: Config, name: str, link: str, target: str,
149
+ journal: Optional[dict]) -> Tuple[Optional[str], Optional[int], str]:
150
+ """判断 restore 从哪一步开始。返回 (状态, 直接结束时的退出码, 说明)。"""
151
+ link_kind, target_kind = entry_kind(link), entry_kind(target)
152
+ points_to_target = link_kind == KIND_LINK and os.path.realpath(link) == os.path.realpath(target)
153
+ registered = config.find(name) is not None
154
+ if journal is None:
155
+ if points_to_target and target_kind == KIND_DIR and registered:
156
+ return STATE_A, None, ""
157
+ if not registered and target_kind == KIND_MISSING:
158
+ return None, None, "D"
159
+ if link_kind == KIND_LINK and registered and not points_to_target:
160
+ return None, accounts.EXIT_CONFLICT, ("~/.codex does not point to {}; run `multi-codex use {}` "
161
+ "first".format(target, name))
162
+ if not registered:
163
+ return None, accounts.EXIT_ERROR, config.not_registered(name)
164
+ if target_kind != KIND_DIR:
165
+ return None, accounts.EXIT_CONFLICT, "{} is not a real directory".format(target)
166
+ return None, accounts.EXIT_CONFLICT, "~/.codex is not a link to {}".format(target)
167
+ # 有标记:只有三项都一致才续跑,避免 HOME 或根目录配置变了以后按旧标记去移动目录。
168
+ if journal["name"].casefold() != name.casefold() or journal["source"] != link or journal["target"] != target:
169
+ return None, accounts.EXIT_CONFLICT, ("an unfinished restore of {!r} ({} -> {}) exists; finish it or "
170
+ "check it by hand".format(journal["name"], journal["target"],
171
+ journal["source"]))
172
+ if points_to_target and target_kind == KIND_DIR:
173
+ return STATE_A2, None, ""
174
+ if link_kind == KIND_MISSING and target_kind == KIND_DIR:
175
+ return STATE_B, None, ""
176
+ if link_kind == KIND_DIR and target_kind == KIND_MISSING:
177
+ return STATE_C, None, ""
178
+ return None, accounts.EXIT_CONFLICT, "unexpected state: ~/.codex is {}, {} is {}".format(
179
+ link_kind, target, target_kind)
180
+
181
+
182
+ def restore_account(config: Config, config_exists: bool, name: str, skip_process_check: bool,
183
+ accept_relogin: bool, dry_run: bool) -> int:
184
+ link = default_link()
185
+ account = config.find(name)
186
+ journal = load_restore_journal()
187
+ # 账号已注销(状态 C 中途)时配置里查不到原名,用标记中记录的名字,用户输入的大小写不同也能续跑。
188
+ if account is not None:
189
+ canonical = account.name
190
+ elif journal is not None and journal["name"].casefold() == name.casefold():
191
+ canonical = journal["name"]
192
+ else:
193
+ canonical = name
194
+ target = accounts.account_dir(config, canonical)
195
+ state, code, message = _restore_state(config, canonical, link, target, journal)
196
+
197
+ if state is None and message == "D":
198
+ # 没有标记、账号未登记、目录也不在:要么从未有过这个账号,要么上一次 restore 写完配置后、
199
+ # 删启动命令前中断了。后一种情况要把残留的启动命令清掉。
200
+ path = launcher.launcher_path(expand(config.bin_dir), canonical)
201
+ owner = launcher.managed_account(path)
202
+ if owner is None or owner.casefold() != canonical.casefold():
203
+ error(config.not_registered(name), phase="restore")
204
+ return accounts.EXIT_ERROR
205
+ if dry_run:
206
+ info("(dry-run) would delete the leftover launcher {}".format(path))
207
+ return accounts.EXIT_OK
208
+ code = accounts.converge(config, config.copy(), config_exists=config_exists, dry_run=False,
209
+ orphan_scope=frozenset([canonical.casefold()]))
210
+ if code == accounts.EXIT_OK:
211
+ info("restore of {} finished".format(canonical))
212
+ return code
213
+ if state is None:
214
+ error(message, phase="restore", path=link)
215
+ return code
216
+ if state != STATE_A:
217
+ info("resuming restore of {} from state {}".format(canonical, state))
218
+
219
+ relogin_needed = bool(journal.get("relogin_needed")) if journal else False
220
+ if state in (STATE_A, STATE_A2, STATE_B):
221
+ parent = os.path.dirname(link)
222
+ if os.stat(target).st_dev != os.stat(parent).st_dev:
223
+ error("{} and ~/.codex are on different file systems; restore by hand: remove the ~/.codex link, "
224
+ "copy {} to ~/.codex, then run `multi-codex remove {}`".format(target, target, canonical),
225
+ phase="restore", path=target)
226
+ return accounts.EXIT_CONFLICT
227
+ if state == STATE_A:
228
+ # 移回 ~/.codex 改变了真实路径,与迁移一样会让钥匙串里的登录失效。
229
+ code, relogin_needed = migrate._check_credentials_store(target, canonical, accept_relogin,
230
+ relogin_command="codex login")
231
+ if code is not None:
232
+ return code
233
+ if skip_process_check:
234
+ warn("--skip-process-check: {} may still be in use".format(target))
235
+ else:
236
+ code = migrate._busy_check(target)
237
+ if code:
238
+ return code
239
+
240
+ if dry_run:
241
+ if state in (STATE_A, STATE_A2):
242
+ info("(dry-run) would unlink ~/.codex")
243
+ if state in (STATE_A, STATE_A2, STATE_B):
244
+ info("(dry-run) would move {} to ~/.codex".format(target))
245
+ info("(dry-run) would unregister {} and delete codex-{}".format(canonical, canonical))
246
+ return accounts.EXIT_OK
247
+
248
+ if state == STATE_A:
249
+ atomic_write(restore_journal_path(), json.dumps({
250
+ "name": canonical, "source": link, "target": target, "relogin_needed": relogin_needed,
251
+ }, indent=2, ensure_ascii=False) + "\n", mode=0o600)
252
+ migrate._test_hook("restore-marked")
253
+ if state in (STATE_A, STATE_A2):
254
+ os.unlink(link)
255
+ info("step=unlink {}".format(link))
256
+ migrate._test_hook("restore-unlinked")
257
+ if state in (STATE_A, STATE_A2, STATE_B):
258
+ try:
259
+ os.rename(target, link)
260
+ except OSError as exc:
261
+ # 第 1、2 步之间有人运行了 codex,新建了非空的 ~/.codex。
262
+ error("cannot move {} to ~/.codex ({}); move the new ~/.codex away and rerun "
263
+ "`multi-codex restore {}`".format(target, exc, canonical), phase="restore", path=link)
264
+ return accounts.EXIT_ERROR
265
+ info("step=rename {} -> {}".format(target, link))
266
+ migrate._test_hook("restore-renamed")
267
+
268
+ info("step=unregister {}".format(canonical))
269
+ new = config.copy()
270
+ current = new.find(canonical)
271
+ if current is not None:
272
+ del new.accounts[current.name]
273
+ binding.drop_account(new, canonical)
274
+ code = accounts.converge(config, new, config_exists=config_exists, dry_run=False,
275
+ orphan_scope=frozenset([canonical.casefold()]))
276
+ if code != accounts.EXIT_OK:
277
+ # 保留标记:现在处于状态 C,修好问题后重跑同一命令即可。
278
+ error("data is back at ~/.codex but unregistering failed; fix the problem above and rerun "
279
+ "`multi-codex restore {}`".format(canonical), phase="restore")
280
+ return code
281
+ if os.path.exists(restore_journal_path()):
282
+ os.unlink(restore_journal_path())
283
+ info("restored {} to ~/.codex".format(canonical))
284
+ if relogin_needed:
285
+ info("log in again: codex login")
286
+ return accounts.EXIT_OK