coderouter-cli 2.13.0__py3-none-any.whl → 2.14.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.
- coderouter/adapters/anthropic_native.py +4 -2
- coderouter/adapters/openai_compat.py +5 -2
- coderouter/cli.py +140 -2
- coderouter/config/loader.py +24 -1
- coderouter/config/schemas.py +144 -0
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/logging.py +7 -0
- coderouter/rollback.py +217 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +6 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +156 -20
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/RECORD +21 -17
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/licenses/LICENSE +0 -0
coderouter/rollback.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Restore the files CodeRouter rewrote (v2.14.0).
|
|
2
|
+
|
|
3
|
+
Two commands in this codebase mutate files the operator owns:
|
|
4
|
+
``doctor --apply`` (providers.yaml, model-capabilities.yaml) and
|
|
5
|
+
``vscode-init`` (.vscode/settings.json, .envrc). Both already wrote a
|
|
6
|
+
``.bak`` sibling before touching anything — and then left the operator to
|
|
7
|
+
find it and copy it back by hand. The stated reasoning was that anyone
|
|
8
|
+
using git already has versioned history. That is true right up until the
|
|
9
|
+
file being rewritten is ``~/.coderouter/providers.yaml``, which is not in
|
|
10
|
+
anybody's repo, or until ``--force`` removes a line the operator wanted
|
|
11
|
+
(v2.11's ``.envrc`` incident, H-11).
|
|
12
|
+
|
|
13
|
+
So this module supplies the missing half: a way to get the old bytes back.
|
|
14
|
+
|
|
15
|
+
Swap, not overwrite
|
|
16
|
+
-------------------
|
|
17
|
+
Restoring does not discard the current file — it writes the current
|
|
18
|
+
contents back out as the new ``.bak`` before putting the old bytes in
|
|
19
|
+
place. Rollback therefore toggles: run it twice and you are where you
|
|
20
|
+
started. A one-way restore turns "I wanted to compare" into "I destroyed
|
|
21
|
+
the newer version", which is the same class of mistake this module exists
|
|
22
|
+
to undo.
|
|
23
|
+
|
|
24
|
+
Metadata
|
|
25
|
+
--------
|
|
26
|
+
Restores go through :func:`shutil.copy2`, so mode and mtime survive the
|
|
27
|
+
round trip. That matters for ``.envrc``, which is deliberately created
|
|
28
|
+
0600 because it carries ``ANTHROPIC_AUTH_TOKEN``; a restore that reset it
|
|
29
|
+
to the default umask would quietly widen a credential file.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
from collections.abc import Iterable, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"BACKUP_SUFFIX",
|
|
42
|
+
"RestoreOutcome",
|
|
43
|
+
"backup_for",
|
|
44
|
+
"discover_managed_files",
|
|
45
|
+
"exit_code_for_rollback",
|
|
46
|
+
"format_rollback_report",
|
|
47
|
+
"restore_file",
|
|
48
|
+
"restore_many",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Both writers use the same convention: the backup is the original name
|
|
52
|
+
# with ``.bak`` appended (``providers.yaml.bak``, ``settings.json.bak``),
|
|
53
|
+
# single generation, overwritten on each apply.
|
|
54
|
+
BACKUP_SUFFIX = ".bak"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def backup_for(path: str | os.PathLike[str]) -> Path:
|
|
58
|
+
"""Return the ``.bak`` sibling of ``path``."""
|
|
59
|
+
p = Path(path)
|
|
60
|
+
return p.with_name(p.name + BACKUP_SUFFIX)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class RestoreOutcome:
|
|
65
|
+
"""What happened (or would happen) for one managed file.
|
|
66
|
+
|
|
67
|
+
``status`` is one of ``restored`` / ``would-restore`` / ``no-backup`` /
|
|
68
|
+
``identical`` / ``failed``. ``identical`` is reported rather than
|
|
69
|
+
silently skipped: an operator who runs rollback and sees nothing wants
|
|
70
|
+
to know the difference between "there was no backup" and "the backup
|
|
71
|
+
already matches what is on disk".
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
path: Path
|
|
75
|
+
backup: Path
|
|
76
|
+
status: str
|
|
77
|
+
detail: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def discover_managed_files(
|
|
81
|
+
*,
|
|
82
|
+
config_path: str | os.PathLike[str] | None = None,
|
|
83
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
84
|
+
) -> list[Path]:
|
|
85
|
+
"""List the files CodeRouter is known to rewrite, in restore order.
|
|
86
|
+
|
|
87
|
+
``config_path`` should be the path the loader actually read — pass it
|
|
88
|
+
in rather than re-deriving it here, for the same reason
|
|
89
|
+
``doctor --apply`` delegates to ``resolve_config_path``: a second copy
|
|
90
|
+
of the search order is exactly how the v2.13.0 CWD opt-in produced a
|
|
91
|
+
file mismatch.
|
|
92
|
+
|
|
93
|
+
The user-layer ``model-capabilities.yaml`` is always included because
|
|
94
|
+
``doctor --apply`` writes there regardless of which config was loaded.
|
|
95
|
+
"""
|
|
96
|
+
paths: list[Path] = []
|
|
97
|
+
if config_path:
|
|
98
|
+
paths.append(Path(config_path).expanduser())
|
|
99
|
+
paths.append(Path.home() / ".coderouter" / "model-capabilities.yaml")
|
|
100
|
+
if workspace:
|
|
101
|
+
root = Path(workspace).expanduser()
|
|
102
|
+
paths.append(root / ".vscode" / "settings.json")
|
|
103
|
+
paths.append(root / ".envrc")
|
|
104
|
+
# Preserve order while dropping duplicates (config_path can legitimately
|
|
105
|
+
# be the user-layer path in some setups).
|
|
106
|
+
seen: set[Path] = set()
|
|
107
|
+
unique: list[Path] = []
|
|
108
|
+
for p in paths:
|
|
109
|
+
if p not in seen:
|
|
110
|
+
seen.add(p)
|
|
111
|
+
unique.append(p)
|
|
112
|
+
return unique
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def restore_file(
|
|
116
|
+
path: str | os.PathLike[str], *, dry_run: bool = False
|
|
117
|
+
) -> RestoreOutcome:
|
|
118
|
+
"""Swap ``path`` with its ``.bak`` sibling.
|
|
119
|
+
|
|
120
|
+
The current contents become the new backup, so the operation is its
|
|
121
|
+
own inverse. A missing backup is not an error — it just means this
|
|
122
|
+
file was never rewritten by us.
|
|
123
|
+
"""
|
|
124
|
+
target = Path(path).expanduser()
|
|
125
|
+
backup = backup_for(target)
|
|
126
|
+
|
|
127
|
+
if not backup.is_file():
|
|
128
|
+
return RestoreOutcome(target, backup, "no-backup", "no .bak sibling found")
|
|
129
|
+
|
|
130
|
+
backup_bytes = backup.read_bytes()
|
|
131
|
+
current_bytes = target.read_bytes() if target.is_file() else None
|
|
132
|
+
if current_bytes == backup_bytes:
|
|
133
|
+
return RestoreOutcome(
|
|
134
|
+
target, backup, "identical", "the backup already matches the live file"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if dry_run:
|
|
138
|
+
return RestoreOutcome(
|
|
139
|
+
target,
|
|
140
|
+
backup,
|
|
141
|
+
"would-restore",
|
|
142
|
+
f"{len(backup_bytes)} bytes would replace "
|
|
143
|
+
f"{'nothing (file absent)' if current_bytes is None else f'{len(current_bytes)} bytes'}",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Write the current contents out as the new backup FIRST. If the
|
|
148
|
+
# copy of the old bytes then fails, the operator has lost nothing:
|
|
149
|
+
# both versions are still on disk.
|
|
150
|
+
if current_bytes is not None:
|
|
151
|
+
tmp = backup.with_name(backup.name + ".swap")
|
|
152
|
+
shutil.copy2(target, tmp)
|
|
153
|
+
shutil.copy2(backup, target)
|
|
154
|
+
os.replace(tmp, backup)
|
|
155
|
+
else:
|
|
156
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
shutil.copy2(backup, target)
|
|
158
|
+
backup.unlink()
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
return RestoreOutcome(target, backup, "failed", str(exc))
|
|
161
|
+
|
|
162
|
+
return RestoreOutcome(
|
|
163
|
+
target,
|
|
164
|
+
backup,
|
|
165
|
+
"restored",
|
|
166
|
+
"current contents kept as the new .bak — re-run to toggle back",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def restore_many(
|
|
171
|
+
paths: Iterable[str | os.PathLike[str]], *, dry_run: bool = False
|
|
172
|
+
) -> list[RestoreOutcome]:
|
|
173
|
+
"""Restore each path, continuing past files that have no backup."""
|
|
174
|
+
return [restore_file(p, dry_run=dry_run) for p in paths]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_STATUS_GLYPH = {
|
|
178
|
+
"restored": "RESTORED",
|
|
179
|
+
"would-restore": "WOULD ",
|
|
180
|
+
"identical": "SAME ",
|
|
181
|
+
"no-backup": "SKIP ",
|
|
182
|
+
"failed": "FAILED ",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def format_rollback_report(
|
|
187
|
+
outcomes: Sequence[RestoreOutcome], *, dry_run: bool
|
|
188
|
+
) -> str:
|
|
189
|
+
"""Render outcomes for the terminal."""
|
|
190
|
+
header = "coderouter rollback" + (" --dry-run" if dry_run else "")
|
|
191
|
+
lines = ["", header, "=" * 52]
|
|
192
|
+
for outcome in outcomes:
|
|
193
|
+
glyph = _STATUS_GLYPH.get(outcome.status, "???? ")
|
|
194
|
+
lines.append(f"[{glyph}] {outcome.path}")
|
|
195
|
+
if outcome.detail:
|
|
196
|
+
lines.append(f" {outcome.detail}")
|
|
197
|
+
restored = sum(o.status in {"restored", "would-restore"} for o in outcomes)
|
|
198
|
+
failed = sum(o.status == "failed" for o in outcomes)
|
|
199
|
+
lines.append("-" * 52)
|
|
200
|
+
verb = "would restore" if dry_run else "restored"
|
|
201
|
+
lines.append(f"{verb}: {restored} file(s); failed: {failed}")
|
|
202
|
+
lines.append("")
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def exit_code_for_rollback(outcomes: Sequence[RestoreOutcome]) -> int:
|
|
207
|
+
"""0 when something was (or would be) restored and nothing failed.
|
|
208
|
+
|
|
209
|
+
A run where every file reports ``no-backup`` exits 2 — "nothing to do"
|
|
210
|
+
is a different answer from "done", and a wrapper script should be able
|
|
211
|
+
to tell them apart without parsing text.
|
|
212
|
+
"""
|
|
213
|
+
if any(o.status == "failed" for o in outcomes):
|
|
214
|
+
return 1
|
|
215
|
+
if any(o.status in {"restored", "would-restore"} for o in outcomes):
|
|
216
|
+
return 0
|
|
217
|
+
return 2
|