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.
- opencode_swap/__init__.py +3 -0
- opencode_swap/__main__.py +4 -0
- opencode_swap/atomic.py +95 -0
- opencode_swap/backup.py +128 -0
- opencode_swap/cli.py +799 -0
- opencode_swap/exceptions.py +60 -0
- opencode_swap/locking.py +70 -0
- opencode_swap/macos_keychain.py +136 -0
- opencode_swap/models.py +190 -0
- opencode_swap/oauth_jwt.py +66 -0
- opencode_swap/oauth_refresh.py +156 -0
- opencode_swap/opencode_auth.py +54 -0
- opencode_swap/paths.py +83 -0
- opencode_swap/process_detection.py +29 -0
- opencode_swap/providers/__init__.py +26 -0
- opencode_swap/providers/api.py +50 -0
- opencode_swap/providers/base.py +91 -0
- opencode_swap/providers/common.py +144 -0
- opencode_swap/providers/github_copilot.py +46 -0
- opencode_swap/providers/openai.py +186 -0
- opencode_swap/providers/poe.py +64 -0
- opencode_swap/providers/xai.py +72 -0
- opencode_swap/providers/zai.py +31 -0
- opencode_swap/sealed.py +83 -0
- opencode_swap/store.py +551 -0
- opencode_swap/switcher.py +952 -0
- opencode_swap/transfer.py +149 -0
- opencode_swap/usage.py +304 -0
- opencode_swap-0.4.0.dist-info/METADATA +307 -0
- opencode_swap-0.4.0.dist-info/RECORD +33 -0
- opencode_swap-0.4.0.dist-info/WHEEL +4 -0
- opencode_swap-0.4.0.dist-info/entry_points.txt +3 -0
- opencode_swap-0.4.0.dist-info/licenses/LICENSE +21 -0
opencode_swap/atomic.py
ADDED
|
@@ -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)
|
opencode_swap/backup.py
ADDED
|
@@ -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")
|