devin-switch 0.1.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.
- devin_switch/__init__.py +12 -0
- devin_switch/cli.py +320 -0
- devin_switch/engine.py +256 -0
- devin_switch/health.py +255 -0
- devin_switch/journal.py +82 -0
- devin_switch/jsonc.py +60 -0
- devin_switch/paths.py +145 -0
- devin_switch/plan.py +220 -0
- devin_switch/profiles/README.md +63 -0
- devin_switch/profiles/corporate/User/settings.json +11 -0
- devin_switch/profiles/corporate/config.json +67 -0
- devin_switch/profiles/corporate/mcp_config.json +15 -0
- devin_switch/profiles/corporate/profile.json +8 -0
- devin_switch/profiles/lab/User/settings.json +11 -0
- devin_switch/profiles/lab/config.json +50 -0
- devin_switch/profiles/lab/mcp_config.json +10 -0
- devin_switch/profiles/lab/profile.json +12 -0
- devin_switch/profiles/personal/User/settings.json +11 -0
- devin_switch/profiles/personal/config.json +82 -0
- devin_switch/profiles/personal/mcp_config.json +27 -0
- devin_switch/profiles/personal/profile.json +8 -0
- devin_switch/profiles.py +96 -0
- devin_switch/redact.py +152 -0
- devin_switch-0.1.0.dist-info/METADATA +214 -0
- devin_switch-0.1.0.dist-info/RECORD +29 -0
- devin_switch-0.1.0.dist-info/WHEEL +5 -0
- devin_switch-0.1.0.dist-info/entry_points.txt +2 -0
- devin_switch-0.1.0.dist-info/licenses/LICENSE +21 -0
- devin_switch-0.1.0.dist-info/top_level.txt +1 -0
devin_switch/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""devin-switch — switch between Devin configuration profiles.
|
|
2
|
+
|
|
3
|
+
Manages the config files in the Devin config/data dirs (``User/settings.json``,
|
|
4
|
+
``config.json`` hooks, ``mcp_config.json`` MCP entries) as declarative
|
|
5
|
+
profiles, with verified snapshots before any write and a rollback journal.
|
|
6
|
+
|
|
7
|
+
``credentials.toml`` is never touched — not even read.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
devin_switch/cli.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Thin CLI wrapper — all logic lives in the library modules.
|
|
2
|
+
|
|
3
|
+
- ``devin-switch list`` — discovered profiles (read-only)
|
|
4
|
+
- ``devin-switch show <profile>`` — what the profile manages (read-only)
|
|
5
|
+
- ``devin-switch diff <a> <b>`` — masked per-file diff between two profiles
|
|
6
|
+
- ``devin-switch use <profile>`` — DRY-RUN by default; ``--apply`` writes
|
|
7
|
+
- ``devin-switch rollback`` — DRY-RUN by default; ``--apply`` restores the
|
|
8
|
+
last journal backup
|
|
9
|
+
- ``devin-switch doctor`` — config sanity + closest-profile ranking
|
|
10
|
+
|
|
11
|
+
Exit codes: 0 on success / clean dry-run; 1 on any error (unknown profile,
|
|
12
|
+
nothing to roll back, snapshot verification failure) or — for ``doctor``
|
|
13
|
+
only — on any FAIL check. ``credentials.toml`` is never touched, ever.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Sequence
|
|
22
|
+
|
|
23
|
+
from devin_switch import engine, journal
|
|
24
|
+
from devin_switch import paths as paths_mod
|
|
25
|
+
from devin_switch.health import check_config_files, rank_profiles
|
|
26
|
+
from devin_switch.paths import Roots, target_for
|
|
27
|
+
from devin_switch.plan import (
|
|
28
|
+
build_plan,
|
|
29
|
+
profile_diff,
|
|
30
|
+
render_plan,
|
|
31
|
+
summarize_json_top_keys,
|
|
32
|
+
)
|
|
33
|
+
from devin_switch.profiles import ProfileError, discover, get
|
|
34
|
+
from devin_switch.redact import is_credential_file, is_never_touch
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _add_roots(p: argparse.ArgumentParser) -> None:
|
|
38
|
+
p.add_argument(
|
|
39
|
+
"--data-dir",
|
|
40
|
+
type=Path,
|
|
41
|
+
default=None,
|
|
42
|
+
help="Devin data dir (default: platform location — "
|
|
43
|
+
"%%APPDATA%%/devin on Windows, ~/.config/devin elsewhere)",
|
|
44
|
+
)
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--config-dir",
|
|
47
|
+
type=Path,
|
|
48
|
+
default=None,
|
|
49
|
+
help="Devin UI config dir holding User/ (default: platform "
|
|
50
|
+
"location; falls back to --data-dir when only that is given)",
|
|
51
|
+
)
|
|
52
|
+
p.add_argument(
|
|
53
|
+
"--profiles-dir",
|
|
54
|
+
type=Path,
|
|
55
|
+
default=None,
|
|
56
|
+
help="directory of profile overlays (default: "
|
|
57
|
+
"$DEVIN_SWITCH_PROFILES_DIR, ./profiles, or bundled examples)",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _add_apply(p: argparse.ArgumentParser) -> None:
|
|
62
|
+
p.add_argument(
|
|
63
|
+
"--apply",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="WRITE MODE: perform the switch after a verified snapshot. "
|
|
66
|
+
"Without it the command is a dry-run and writes nothing.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
71
|
+
parser = argparse.ArgumentParser(
|
|
72
|
+
prog="devin-switch",
|
|
73
|
+
description="Switch between Devin configuration profiles (hooks, "
|
|
74
|
+
"MCP, models) with snapshot and verification. Read-only unless "
|
|
75
|
+
"--apply is passed; credentials.toml is never touched.",
|
|
76
|
+
)
|
|
77
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
78
|
+
|
|
79
|
+
p_list = sub.add_parser("list", help="list discovered profiles")
|
|
80
|
+
_add_roots(p_list)
|
|
81
|
+
|
|
82
|
+
p_show = sub.add_parser(
|
|
83
|
+
"show", help="show what a profile manages (values masked)"
|
|
84
|
+
)
|
|
85
|
+
_add_roots(p_show)
|
|
86
|
+
p_show.add_argument("profile")
|
|
87
|
+
|
|
88
|
+
p_diff = sub.add_parser(
|
|
89
|
+
"diff", help="masked diff between two profiles (read-only)"
|
|
90
|
+
)
|
|
91
|
+
_add_roots(p_diff)
|
|
92
|
+
p_diff.add_argument("a")
|
|
93
|
+
p_diff.add_argument("b")
|
|
94
|
+
|
|
95
|
+
p_use = sub.add_parser(
|
|
96
|
+
"use", help="preview a switch (dry-run) or apply it with --apply"
|
|
97
|
+
)
|
|
98
|
+
_add_roots(p_use)
|
|
99
|
+
_add_apply(p_use)
|
|
100
|
+
p_use.add_argument("profile")
|
|
101
|
+
|
|
102
|
+
p_rb = sub.add_parser(
|
|
103
|
+
"rollback",
|
|
104
|
+
help="preview restoring the last backup (dry-run) or restore it "
|
|
105
|
+
"with --apply",
|
|
106
|
+
)
|
|
107
|
+
_add_roots(p_rb)
|
|
108
|
+
_add_apply(p_rb)
|
|
109
|
+
|
|
110
|
+
p_doc = sub.add_parser(
|
|
111
|
+
"doctor",
|
|
112
|
+
help="config sanity checks + closest-profile ranking (read-only)",
|
|
113
|
+
)
|
|
114
|
+
_add_roots(p_doc)
|
|
115
|
+
|
|
116
|
+
return parser
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _roots(args: argparse.Namespace) -> Roots:
|
|
120
|
+
data_dir = (args.data_dir or paths_mod.default_data_dir()).expanduser()
|
|
121
|
+
if args.config_dir is not None:
|
|
122
|
+
config_dir = args.config_dir.expanduser()
|
|
123
|
+
elif args.data_dir is not None:
|
|
124
|
+
# single-root mode: one dir holds both data files and User/
|
|
125
|
+
config_dir = data_dir
|
|
126
|
+
else:
|
|
127
|
+
config_dir = paths_mod.default_config_dir()
|
|
128
|
+
return Roots(
|
|
129
|
+
data_dir=data_dir,
|
|
130
|
+
config_dir=config_dir,
|
|
131
|
+
profiles_dir=paths_mod.resolve_profiles_dir(args.profiles_dir),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# subcommands
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_list(roots: Roots) -> int:
|
|
141
|
+
profiles = discover(roots.profiles_dir)
|
|
142
|
+
if not profiles:
|
|
143
|
+
print(f"no profiles found in {roots.profiles_dir}")
|
|
144
|
+
print("create profiles/<name>/ with config overlays — see README")
|
|
145
|
+
return 0
|
|
146
|
+
print(f"profiles in {roots.profiles_dir}:")
|
|
147
|
+
for profile in profiles.values():
|
|
148
|
+
n = len(profile.files)
|
|
149
|
+
desc = f" — {profile.description}" if profile.description else ""
|
|
150
|
+
print(f" {profile.name:<16} {n:>2} file(s){desc}")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_show(roots: Roots, name: str) -> int:
|
|
155
|
+
profile = get(roots.profiles_dir, name)
|
|
156
|
+
print(f"profile: {profile.name}")
|
|
157
|
+
print(f"root: {profile.root}")
|
|
158
|
+
if profile.description:
|
|
159
|
+
print(f"desc: {profile.description}")
|
|
160
|
+
for note in profile.notes:
|
|
161
|
+
print(f"note: {note}")
|
|
162
|
+
print("files:")
|
|
163
|
+
for rel in sorted(profile.files):
|
|
164
|
+
src = profile.files[rel]
|
|
165
|
+
size = src.stat().st_size
|
|
166
|
+
if is_never_touch(rel):
|
|
167
|
+
target_desc = "(never managed — skipped)"
|
|
168
|
+
else:
|
|
169
|
+
try:
|
|
170
|
+
target_desc = str(target_for(rel, roots))
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
target_desc = f"(unsafe — {exc})"
|
|
173
|
+
print(f" {rel} [{size} B]")
|
|
174
|
+
print(f" -> {target_desc}")
|
|
175
|
+
if is_credential_file(rel) or is_never_touch(rel):
|
|
176
|
+
print(" keys: <withheld — credential-carrying file>")
|
|
177
|
+
continue
|
|
178
|
+
keys = summarize_json_top_keys(src.read_bytes())
|
|
179
|
+
if keys:
|
|
180
|
+
print(f" keys: {', '.join(keys)}")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_diff(roots: Roots, a: str, b: str) -> int:
|
|
185
|
+
profile_a = get(roots.profiles_dir, a)
|
|
186
|
+
profile_b = get(roots.profiles_dir, b)
|
|
187
|
+
print(f"diff {a} -> {b} (values masked):")
|
|
188
|
+
print(profile_diff(profile_a, profile_b))
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _plan_counts(plans) -> dict[str, int]:
|
|
193
|
+
counts: dict[str, int] = {}
|
|
194
|
+
for fp in plans:
|
|
195
|
+
counts[fp.action] = counts.get(fp.action, 0) + 1
|
|
196
|
+
return counts
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_use(roots: Roots, name: str, apply: bool) -> int:
|
|
200
|
+
profile = get(roots.profiles_dir, name)
|
|
201
|
+
plans = build_plan(profile, roots)
|
|
202
|
+
counts = _plan_counts(plans)
|
|
203
|
+
summary = ", ".join(
|
|
204
|
+
f"{n} {action}" for action, n in sorted(counts.items())
|
|
205
|
+
)
|
|
206
|
+
if not apply:
|
|
207
|
+
print(f"DRY-RUN use {name} — nothing will be written ({summary})")
|
|
208
|
+
print(render_plan(plans))
|
|
209
|
+
print()
|
|
210
|
+
print("re-run with --apply to snapshot, verify and write")
|
|
211
|
+
return 0
|
|
212
|
+
try:
|
|
213
|
+
result = engine.apply_plan(name, plans, roots)
|
|
214
|
+
except engine.SnapshotError as exc:
|
|
215
|
+
print(f"ABORTED — snapshot verification failed; no files written",
|
|
216
|
+
file=sys.stderr)
|
|
217
|
+
for problem in exc.problems:
|
|
218
|
+
print(f" {problem}", file=sys.stderr)
|
|
219
|
+
print(f" (backup kept for inspection: {exc.backup_dir})",
|
|
220
|
+
file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
if result.files_changed:
|
|
223
|
+
print(f"applied profile '{name}' — {len(result.files_changed)} "
|
|
224
|
+
"file(s) written:")
|
|
225
|
+
for rel in result.files_changed:
|
|
226
|
+
print(f" wrote {rel}")
|
|
227
|
+
print(f"backup: {result.backup_dir}")
|
|
228
|
+
else:
|
|
229
|
+
print(f"profile '{name}' already in effect — nothing to write")
|
|
230
|
+
if result.skipped:
|
|
231
|
+
print(f"skipped (never managed): {', '.join(result.skipped)}")
|
|
232
|
+
print(f"journal: {roots.journal_path}")
|
|
233
|
+
print("undo: devin-switch rollback --apply")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_rollback(roots: Roots, apply: bool) -> int:
|
|
238
|
+
entry = journal.last_use_entry(roots.journal_path)
|
|
239
|
+
if entry is None:
|
|
240
|
+
print("nothing to roll back — no 'use' entry with an existing "
|
|
241
|
+
"backup in the journal", file=sys.stderr)
|
|
242
|
+
return 1
|
|
243
|
+
try:
|
|
244
|
+
steps = engine.plan_rollback(entry, roots)
|
|
245
|
+
except (FileNotFoundError, engine.SnapshotError, ValueError) as exc:
|
|
246
|
+
print(f"cannot roll back: {exc}", file=sys.stderr)
|
|
247
|
+
return 1
|
|
248
|
+
if not apply:
|
|
249
|
+
print(f"DRY-RUN rollback — would undo 'use {entry.get('profile')}' "
|
|
250
|
+
f"from {entry.get('ts')} (nothing written)")
|
|
251
|
+
for step in steps:
|
|
252
|
+
verb = "restore" if step.action == "restore" else "delete "
|
|
253
|
+
print(f" {verb} {step.target}")
|
|
254
|
+
print()
|
|
255
|
+
print("re-run with --apply to restore the backup")
|
|
256
|
+
return 0
|
|
257
|
+
result = engine.apply_rollback(entry, steps, roots)
|
|
258
|
+
print(f"rolled back to pre-'use {result.profile}' state:")
|
|
259
|
+
for step in result.steps:
|
|
260
|
+
verb = "restored" if step.action == "restore" else "deleted "
|
|
261
|
+
print(f" {verb} {step.target}")
|
|
262
|
+
print(f"journal: {roots.journal_path}")
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_doctor(roots: Roots) -> int:
|
|
267
|
+
results = check_config_files(roots)
|
|
268
|
+
worst = 0
|
|
269
|
+
for r in results:
|
|
270
|
+
print(f"{r.status:<4} {r.label} — {r.detail}")
|
|
271
|
+
if r.status == "FAIL":
|
|
272
|
+
worst = 1
|
|
273
|
+
print()
|
|
274
|
+
profiles = discover(roots.profiles_dir)
|
|
275
|
+
if profiles:
|
|
276
|
+
ranked = rank_profiles(profiles, roots)
|
|
277
|
+
best, dist = ranked[0]
|
|
278
|
+
noun = "file" if dist == 1 else "files"
|
|
279
|
+
state = "exactly matches" if dist == 0 else f"{dist} {noun} away"
|
|
280
|
+
print(f"closest profile: {best.name} ({state})")
|
|
281
|
+
for profile, d in ranked[1:]:
|
|
282
|
+
noun = "file" if d == 1 else "files"
|
|
283
|
+
print(f" {profile.name}: {d} {noun} away")
|
|
284
|
+
else:
|
|
285
|
+
print(f"no profiles in {roots.profiles_dir} — nothing to rank")
|
|
286
|
+
return worst
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# entry point
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
295
|
+
for stream in (sys.stdout, sys.stderr):
|
|
296
|
+
if hasattr(stream, "reconfigure"):
|
|
297
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
298
|
+
args = build_parser().parse_args(argv)
|
|
299
|
+
roots = _roots(args)
|
|
300
|
+
try:
|
|
301
|
+
if args.command == "list":
|
|
302
|
+
return cmd_list(roots)
|
|
303
|
+
if args.command == "show":
|
|
304
|
+
return cmd_show(roots, args.profile)
|
|
305
|
+
if args.command == "diff":
|
|
306
|
+
return cmd_diff(roots, args.a, args.b)
|
|
307
|
+
if args.command == "use":
|
|
308
|
+
return cmd_use(roots, args.profile, args.apply)
|
|
309
|
+
if args.command == "rollback":
|
|
310
|
+
return cmd_rollback(roots, args.apply)
|
|
311
|
+
if args.command == "doctor":
|
|
312
|
+
return cmd_doctor(roots)
|
|
313
|
+
except ProfileError as exc:
|
|
314
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
315
|
+
return 1
|
|
316
|
+
return 2
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
raise SystemExit(main())
|
devin_switch/engine.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The write path — snapshot, verify, apply, rollback.
|
|
2
|
+
|
|
3
|
+
Order of operations for ``use <profile> --apply``:
|
|
4
|
+
|
|
5
|
+
1. snapshot every managed file the profile will change into
|
|
6
|
+
``.devin-ecosystem/switch-backups/<ts>/`` (files that do not exist yet
|
|
7
|
+
are recorded as ``existed: false`` in the manifest — nothing copied);
|
|
8
|
+
2. verify sha256 of every backup copy against both the recorded hash and
|
|
9
|
+
the live source — refuse to write anything on mismatch;
|
|
10
|
+
3. write the overlay atomically (sibling tmp file + ``os.replace``);
|
|
11
|
+
4. append the journal entry.
|
|
12
|
+
|
|
13
|
+
``credentials.toml`` is excluded upstream (``plan`` marks it ``skip``) and
|
|
14
|
+
never reaches this module's file loops.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from devin_switch import journal
|
|
28
|
+
from devin_switch.paths import Roots, target_for
|
|
29
|
+
from devin_switch.plan import FilePlan
|
|
30
|
+
|
|
31
|
+
MANIFEST_NAME = "manifest.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SnapshotError(Exception):
|
|
35
|
+
"""Snapshot verification failed — nothing was applied."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, problems: list[str], backup_dir: Path):
|
|
38
|
+
self.problems = problems
|
|
39
|
+
self.backup_dir = backup_dir
|
|
40
|
+
super().__init__("; ".join(problems))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sha256_file(path: Path) -> str:
|
|
44
|
+
digest = hashlib.sha256()
|
|
45
|
+
with path.open("rb") as fh:
|
|
46
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
47
|
+
digest.update(chunk)
|
|
48
|
+
return digest.hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def atomic_write(target: Path, data: bytes) -> None:
|
|
52
|
+
"""Write via a sibling tmp file + ``os.replace`` (same-dir rename is
|
|
53
|
+
atomic on POSIX and Windows)."""
|
|
54
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
tmp = target.with_name(target.name + ".devin-switch.tmp")
|
|
56
|
+
tmp.write_bytes(data)
|
|
57
|
+
os.replace(tmp, target)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unique_backup_dir(roots: Roots, stamp: str) -> Path:
|
|
61
|
+
base = roots.backups_dir / stamp
|
|
62
|
+
candidate, i = base, 1
|
|
63
|
+
while candidate.exists():
|
|
64
|
+
i += 1
|
|
65
|
+
candidate = Path(f"{base}-{i}")
|
|
66
|
+
return candidate
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ApplyResult:
|
|
71
|
+
profile: str
|
|
72
|
+
files_changed: list[str]
|
|
73
|
+
backup_dir: Path | None
|
|
74
|
+
journal_entry: dict[str, Any] | None
|
|
75
|
+
skipped: list[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def snapshot(
|
|
79
|
+
plans: list[FilePlan], backup_dir: Path, roots: Roots
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Copy the pre-switch bytes of every file about to change.
|
|
82
|
+
|
|
83
|
+
The manifest records, per managed file: the profile-relative path, the
|
|
84
|
+
absolute target, whether it existed and its sha256 (when it did).
|
|
85
|
+
"""
|
|
86
|
+
backup_dir.mkdir(parents=True, exist_ok=False)
|
|
87
|
+
files: list[dict[str, Any]] = []
|
|
88
|
+
for fp in plans:
|
|
89
|
+
if not fp.needs_write:
|
|
90
|
+
continue
|
|
91
|
+
record: dict[str, Any] = {
|
|
92
|
+
"rel": fp.rel,
|
|
93
|
+
"target": str(fp.target),
|
|
94
|
+
"existed": fp.old_bytes is not None,
|
|
95
|
+
"sha256": None,
|
|
96
|
+
}
|
|
97
|
+
if fp.old_bytes is not None:
|
|
98
|
+
dest = backup_dir / fp.rel
|
|
99
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
shutil.copy2(fp.target, dest)
|
|
101
|
+
record["sha256"] = sha256_file(dest)
|
|
102
|
+
files.append(record)
|
|
103
|
+
manifest = {
|
|
104
|
+
"version": 1,
|
|
105
|
+
"files": files,
|
|
106
|
+
}
|
|
107
|
+
(backup_dir / MANIFEST_NAME).write_text(
|
|
108
|
+
json.dumps(manifest, indent=2), encoding="utf-8"
|
|
109
|
+
)
|
|
110
|
+
return manifest
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def verify_snapshot(manifest: dict[str, Any], backup_dir: Path) -> list[str]:
|
|
114
|
+
"""Every backup copy must hash to its recorded sha256 AND still match
|
|
115
|
+
the live source file (which could have changed mid-switch)."""
|
|
116
|
+
problems: list[str] = []
|
|
117
|
+
for record in manifest.get("files", []):
|
|
118
|
+
if not record.get("existed"):
|
|
119
|
+
continue
|
|
120
|
+
rel, recorded = record["rel"], record["sha256"]
|
|
121
|
+
copy = backup_dir / rel
|
|
122
|
+
if not copy.is_file():
|
|
123
|
+
problems.append(f"{rel}: backup copy missing")
|
|
124
|
+
continue
|
|
125
|
+
if sha256_file(copy) != recorded:
|
|
126
|
+
problems.append(f"{rel}: backup copy sha256 mismatch")
|
|
127
|
+
continue
|
|
128
|
+
source = Path(record["target"])
|
|
129
|
+
if not source.is_file() or sha256_file(source) != recorded:
|
|
130
|
+
problems.append(
|
|
131
|
+
f"{rel}: source changed between snapshot and verify"
|
|
132
|
+
)
|
|
133
|
+
return problems
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def apply_plan(
|
|
137
|
+
profile_name: str, plans: list[FilePlan], roots: Roots
|
|
138
|
+
) -> ApplyResult:
|
|
139
|
+
"""Snapshot → verify → write → journal. Raises ``SnapshotError``
|
|
140
|
+
before any file is written when verification fails."""
|
|
141
|
+
changed = [fp for fp in plans if fp.needs_write]
|
|
142
|
+
skipped = [fp.rel for fp in plans if fp.action == "skip"]
|
|
143
|
+
if not changed:
|
|
144
|
+
entry = journal.append(
|
|
145
|
+
roots.journal_path, "use", profile_name, [], None
|
|
146
|
+
)
|
|
147
|
+
return ApplyResult(profile_name, [], None, entry, skipped)
|
|
148
|
+
|
|
149
|
+
backup_dir = _unique_backup_dir(roots, journal.backup_stamp())
|
|
150
|
+
manifest = snapshot(changed, backup_dir, roots)
|
|
151
|
+
problems = verify_snapshot(manifest, backup_dir)
|
|
152
|
+
if problems:
|
|
153
|
+
# Leave the backup dir in place — it is the evidence of what the
|
|
154
|
+
# files looked like when the switch was attempted.
|
|
155
|
+
raise SnapshotError(problems, backup_dir)
|
|
156
|
+
|
|
157
|
+
for fp in changed:
|
|
158
|
+
atomic_write(fp.target, fp.new_bytes)
|
|
159
|
+
|
|
160
|
+
files_changed = [fp.rel for fp in changed]
|
|
161
|
+
entry = journal.append(
|
|
162
|
+
roots.journal_path, "use", profile_name, files_changed, backup_dir
|
|
163
|
+
)
|
|
164
|
+
return ApplyResult(profile_name, files_changed, backup_dir, entry, skipped)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# rollback
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class RollbackStep:
|
|
174
|
+
rel: str
|
|
175
|
+
target: Path
|
|
176
|
+
action: str # "restore" | "delete"
|
|
177
|
+
backup_copy: Path | None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class RollbackResult:
|
|
182
|
+
profile: str
|
|
183
|
+
steps: list[RollbackStep]
|
|
184
|
+
journal_entry: dict[str, Any]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _load_manifest(backup_dir: Path) -> dict[str, Any]:
|
|
188
|
+
manifest_path = backup_dir / MANIFEST_NAME
|
|
189
|
+
if not manifest_path.is_file():
|
|
190
|
+
raise FileNotFoundError(
|
|
191
|
+
f"backup manifest missing: {manifest_path}"
|
|
192
|
+
)
|
|
193
|
+
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def plan_rollback(entry: dict[str, Any], roots: Roots) -> list[RollbackStep]:
|
|
197
|
+
"""Steps to undo a ``use`` entry: restore files that existed, delete
|
|
198
|
+
files the switch created."""
|
|
199
|
+
backup_dir = Path(entry["backup_dir"])
|
|
200
|
+
manifest = _load_manifest(backup_dir)
|
|
201
|
+
steps: list[RollbackStep] = []
|
|
202
|
+
for record in manifest.get("files", []):
|
|
203
|
+
rel = record["rel"]
|
|
204
|
+
target = Path(record["target"])
|
|
205
|
+
if record["existed"]:
|
|
206
|
+
copy = backup_dir / rel
|
|
207
|
+
expected = record.get("sha256")
|
|
208
|
+
if not copy.is_file():
|
|
209
|
+
raise FileNotFoundError(
|
|
210
|
+
f"backup copy missing for {rel}: {copy}"
|
|
211
|
+
)
|
|
212
|
+
if expected and sha256_file(copy) != expected:
|
|
213
|
+
raise SnapshotError(
|
|
214
|
+
[f"{rel}: backup copy sha256 mismatch — refusing to "
|
|
215
|
+
"restore a corrupted snapshot"],
|
|
216
|
+
backup_dir,
|
|
217
|
+
)
|
|
218
|
+
steps.append(RollbackStep(rel, target, "restore", copy))
|
|
219
|
+
else:
|
|
220
|
+
steps.append(RollbackStep(rel, target, "delete", None))
|
|
221
|
+
return steps
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _prune_empty_dirs(path: Path, stop_at: Path) -> None:
|
|
225
|
+
path = path.parent
|
|
226
|
+
while path != stop_at and stop_at in path.parents:
|
|
227
|
+
try:
|
|
228
|
+
path.rmdir()
|
|
229
|
+
except OSError:
|
|
230
|
+
break
|
|
231
|
+
path = path.parent
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def apply_rollback(
|
|
235
|
+
entry: dict[str, Any], steps: list[RollbackStep], roots: Roots
|
|
236
|
+
) -> RollbackResult:
|
|
237
|
+
for step in steps:
|
|
238
|
+
if step.action == "restore":
|
|
239
|
+
assert step.backup_copy is not None
|
|
240
|
+
atomic_write(step.target, step.backup_copy.read_bytes())
|
|
241
|
+
else:
|
|
242
|
+
if step.target.is_file():
|
|
243
|
+
step.target.unlink()
|
|
244
|
+
_prune_empty_dirs(step.target, roots.data_dir)
|
|
245
|
+
_prune_empty_dirs(step.target, roots.config_dir)
|
|
246
|
+
files_changed = [s.rel for s in steps]
|
|
247
|
+
entry_out = journal.append(
|
|
248
|
+
roots.journal_path,
|
|
249
|
+
"rollback",
|
|
250
|
+
str(entry.get("profile", "")),
|
|
251
|
+
files_changed,
|
|
252
|
+
None,
|
|
253
|
+
)
|
|
254
|
+
return RollbackResult(
|
|
255
|
+
str(entry.get("profile", "")), steps, entry_out
|
|
256
|
+
)
|