opencode-swap 0.4.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,3 @@
1
+ """opencode-swap: multi-account switcher for OpenCode."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,4 @@
1
+ from opencode_swap.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,95 @@
1
+ """Shared atomic-write primitives: 0600 temp file in the target's own
2
+ directory, fsynced, then os.replace, with the containing directory fsynced
3
+ afterward. The only publish point is the rename, so a crash mid-write never
4
+ leaves a truncated or partially-written file behind, and the fsyncs mean the
5
+ guarantee holds across a power loss, not just a process crash.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from contextlib import suppress
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ DEFAULT_MODE = 0o600
18
+
19
+
20
+ def _fsync_dir(directory: Path) -> None:
21
+ """Best-effort: make the preceding rename itself durable, not just its
22
+ contents. Suppressed because the publish has already committed by this
23
+ point, and some filesystems (notably network ones) reject fsync on a
24
+ directory fd."""
25
+ with suppress(OSError):
26
+ fd = os.open(directory, os.O_RDONLY)
27
+ try:
28
+ os.fsync(fd)
29
+ finally:
30
+ os.close(fd)
31
+
32
+
33
+ def atomic_write_bytes(path: Path, data: bytes, mode: int = DEFAULT_MODE) -> None:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
36
+ tmp_path = Path(tmp_name)
37
+ try:
38
+ with os.fdopen(fd, "wb") as f:
39
+ f.write(data)
40
+ f.flush()
41
+ os.fsync(f.fileno())
42
+ os.chmod(tmp_path, mode)
43
+ os.replace(tmp_path, path)
44
+ except BaseException:
45
+ tmp_path.unlink(missing_ok=True)
46
+ raise
47
+ _fsync_dir(path.parent)
48
+
49
+
50
+ def atomic_write_bytes_exclusive(path: Path, data: bytes, mode: int = DEFAULT_MODE) -> None:
51
+ """Atomically create bytes only if `path` does not already exist."""
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
54
+ tmp_path = Path(tmp_name)
55
+ reserved = False
56
+ try:
57
+ with os.fdopen(fd, "wb") as f:
58
+ f.write(data)
59
+ f.flush()
60
+ os.fsync(f.fileno())
61
+ os.chmod(tmp_path, mode)
62
+ try:
63
+ os.link(tmp_path, path)
64
+ except FileExistsError:
65
+ raise # exclusivity is the point; never fall back
66
+ except OSError:
67
+ # Destination filesystem has no hardlinks (e.g. a FAT32/exFAT
68
+ # export target). `open(O_CREAT|O_EXCL)` is the only race-safe
69
+ # exclusive-create primitive left without them. Once it reserves
70
+ # `path`, publish tmp_path's already-fsynced content over the
71
+ # reservation with the same atomic os.replace used everywhere
72
+ # else in this module -- so a failure partway through can never
73
+ # leave a truncated file under the real name; at worst it leaves
74
+ # the empty reservation, which the handler below also cleans up.
75
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL, mode))
76
+ reserved = True
77
+ os.replace(tmp_path, path)
78
+ except BaseException:
79
+ tmp_path.unlink(missing_ok=True)
80
+ if reserved:
81
+ path.unlink(missing_ok=True)
82
+ raise
83
+ with suppress(OSError):
84
+ tmp_path.unlink()
85
+ _fsync_dir(path.parent)
86
+
87
+
88
+ def atomic_write_json(path: Path, data: Any, mode: int = DEFAULT_MODE, indent: int = 2) -> None:
89
+ atomic_write_bytes(path, json.dumps(data, indent=indent, allow_nan=False).encode("utf-8"), mode=mode)
90
+
91
+
92
+ def atomic_write_json_exclusive(path: Path, data: Any, mode: int = DEFAULT_MODE, indent: int = 2) -> None:
93
+ """Atomically create JSON only if `path` does not already exist."""
94
+ encoded = json.dumps(data, indent=indent, allow_nan=False).encode("utf-8")
95
+ atomic_write_bytes_exclusive(path, encoded, mode=mode)
@@ -0,0 +1,128 @@
1
+ """Recovery snapshots of OpenCode's auth.json, kept under
2
+ ``<data_root>/backups/``. All files 0600.
3
+
4
+ - ``auth.json.bak`` — the live auth.json content immediately before the most
5
+ recent switch. Lets a user manually recover if opencode-swap's own
6
+ transaction rollback (see switcher.py) can't run (e.g. opencode-swap was
7
+ killed mid-operation).
8
+ - ``auth.json.pristine`` — the very first live auth.json content ever seen,
9
+ written once and never overwritten. The ultimate fallback: what OpenCode's
10
+ auth looked like before opencode-swap ever touched it.
11
+ - ``unclaimed-<provider>-<timestamp>-<suffix>.json`` — a live provider record that
12
+ didn't belong to any managed account at switch time (an external
13
+ `opencode auth login` opencode-swap wasn't told about). Preserved instead
14
+ of silently overwritten so nothing is lost.
15
+ - ``discarded-restore-<timestamp>-<suffix>.json`` — a `.restore` recovery
16
+ snapshot archived by ``restore --discard-pending`` before the pending
17
+ marker is cleared, so the one remaining copy of a previous switch's
18
+ pre-restore state is never destroyed outright.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import secrets
26
+ import time
27
+ from pathlib import Path
28
+ from typing import cast
29
+
30
+ from opencode_swap.atomic import atomic_write_json, atomic_write_json_exclusive
31
+ from opencode_swap.exceptions import BackupError
32
+ from opencode_swap.models import JsonObject
33
+
34
+ BACKUP_DIRNAME = "backups"
35
+ BAK_FILENAME = "auth.json.bak"
36
+ PRISTINE_FILENAME = "auth.json.pristine"
37
+ RESTORE_SNAPSHOT_FILENAME = "auth.json.restore"
38
+
39
+
40
+ def _backups_dir(data_root: Path) -> Path:
41
+ """Return the backups dir, creating it with 0700 if needed.
42
+
43
+ atomic_write_json's own mkdir doesn't set a mode (it's shared with
44
+ OpenCode's own auth.json directory, which opencode-swap must not
45
+ presumptuously chmod) — so opencode-swap's own subdirectories are
46
+ responsible for locking themselves down, same as store.py's secrets/.
47
+ """
48
+ d = data_root / BACKUP_DIRNAME
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ d.chmod(0o700)
51
+ return d
52
+
53
+
54
+ def write_bak(data_root: Path, auth: JsonObject) -> None:
55
+ atomic_write_json(_backups_dir(data_root) / BAK_FILENAME, auth)
56
+
57
+
58
+ def read_bak(data_root: Path) -> JsonObject | None:
59
+ return _read_snapshot(_backups_dir(data_root) / BAK_FILENAME)
60
+
61
+
62
+ def write_pristine_if_absent(data_root: Path, auth: JsonObject) -> None:
63
+ path = _backups_dir(data_root) / PRISTINE_FILENAME
64
+ if path.exists():
65
+ return
66
+ atomic_write_json(path, auth)
67
+
68
+
69
+ def read_pristine(data_root: Path) -> JsonObject | None:
70
+ return _read_snapshot(_backups_dir(data_root) / PRISTINE_FILENAME)
71
+
72
+
73
+ def _read_snapshot(path: Path) -> JsonObject | None:
74
+ if not path.exists():
75
+ return None
76
+ try:
77
+ data = json.loads(path.read_text(encoding="utf-8"))
78
+ except (OSError, json.JSONDecodeError) as exc:
79
+ raise BackupError(f"could not read backup at {path}: {exc}") from exc
80
+ if not isinstance(data, dict):
81
+ raise BackupError(f"backup at {path} does not contain a JSON object at the top level")
82
+ return cast(JsonObject, data)
83
+
84
+
85
+ def write_restore_snapshot(data_root: Path, auth: JsonObject) -> None:
86
+ """Keep restore source durable while .bak is chained to current live state."""
87
+ atomic_write_json(_backups_dir(data_root) / RESTORE_SNAPSHOT_FILENAME, auth)
88
+
89
+
90
+ def read_restore_snapshot(data_root: Path) -> JsonObject | None:
91
+ return _read_snapshot(_backups_dir(data_root) / RESTORE_SNAPSHOT_FILENAME)
92
+
93
+
94
+ def remove_restore_snapshot(data_root: Path) -> None:
95
+ (_backups_dir(data_root) / RESTORE_SNAPSHOT_FILENAME).unlink(missing_ok=True)
96
+
97
+
98
+ def write_discarded_restore(data_root: Path, auth: JsonObject) -> Path:
99
+ """Archive a `.restore` recovery snapshot before its pending marker is
100
+ dropped (see switcher.py's `restore(discard_pending=True)`), so
101
+ discarding it never destroys the only remaining copy of a previous
102
+ switch's pre-restore state."""
103
+ timestamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
104
+ directory = _backups_dir(data_root)
105
+ for _ in range(100):
106
+ path = directory / f"discarded-restore-{timestamp}-{secrets.token_hex(8)}.json"
107
+ try:
108
+ atomic_write_json_exclusive(path, auth)
109
+ except FileExistsError:
110
+ continue
111
+ return path
112
+ raise BackupError("could not allocate a unique discarded-restore backup")
113
+
114
+
115
+ def write_unclaimed(data_root: Path, provider_id: str, record: JsonObject) -> Path:
116
+ timestamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
117
+ directory = _backups_dir(data_root)
118
+ # Random suffixes make collisions improbable; exclusive publication makes
119
+ # even a repeated suffix unable to replace another foreign credential.
120
+ provider_tag = hashlib.sha256(provider_id.encode("utf-8")).hexdigest()[:12]
121
+ for _ in range(100):
122
+ path = directory / f"unclaimed-{provider_tag}-{timestamp}-{secrets.token_hex(8)}.json"
123
+ try:
124
+ atomic_write_json_exclusive(path, record)
125
+ except FileExistsError:
126
+ continue
127
+ return path
128
+ raise BackupError("could not allocate a unique unclaimed credential backup")