cdx-manager 0.3.3 → 0.4.0
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.
- package/README.md +183 -13
- package/bin/cdx +5 -2
- package/changelogs/CHANGELOGS_0_3_4.md +31 -0
- package/changelogs/CHANGELOGS_0_4_0.md +36 -0
- package/checksums/release-archives.json +9 -0
- package/install.ps1 +102 -0
- package/install.sh +51 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +134 -0
- package/src/cli.py +111 -13
- package/src/cli_commands.py +360 -38
- package/src/health.py +13 -1
- package/src/provider_runtime.py +10 -1
- package/src/session_service.py +185 -2
- package/src/session_store.py +11 -0
- package/src/status_source.py +3 -1
- package/src/update_check.py +107 -0
package/src/session_service.py
CHANGED
|
@@ -2,10 +2,12 @@ import os
|
|
|
2
2
|
import shutil
|
|
3
3
|
import json
|
|
4
4
|
import base64
|
|
5
|
+
import sys
|
|
5
6
|
import tempfile
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from urllib.parse import quote
|
|
8
9
|
|
|
10
|
+
from .backup_bundle import decode_bundle, encode_bundle
|
|
9
11
|
from .config import get_cdx_home
|
|
10
12
|
from .errors import CdxError
|
|
11
13
|
from .session_store import create_session_store
|
|
@@ -13,12 +15,15 @@ from .status_source import find_latest_status_artifact
|
|
|
13
15
|
|
|
14
16
|
DEFAULT_PROVIDER = "codex"
|
|
15
17
|
ALLOWED_PROVIDERS = {"codex", "claude"}
|
|
18
|
+
MAX_SESSION_NAME_LENGTH = 64
|
|
16
19
|
RESERVED_SESSION_NAMES = {
|
|
17
20
|
"add",
|
|
18
21
|
"clean",
|
|
19
22
|
"cp",
|
|
20
23
|
"doctor",
|
|
24
|
+
"export",
|
|
21
25
|
"help",
|
|
26
|
+
"import",
|
|
22
27
|
"login",
|
|
23
28
|
"logout",
|
|
24
29
|
"mv",
|
|
@@ -40,10 +45,27 @@ def _encode(name):
|
|
|
40
45
|
return quote(name, safe="")
|
|
41
46
|
|
|
42
47
|
|
|
48
|
+
def _ensure_private_dir(path):
|
|
49
|
+
os.makedirs(path, exist_ok=True)
|
|
50
|
+
if sys.platform == "win32":
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(path, 0o700)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
43
58
|
def _local_now_iso():
|
|
44
59
|
return datetime.now().astimezone().isoformat()
|
|
45
60
|
|
|
46
61
|
|
|
62
|
+
def _safe_relpath(path):
|
|
63
|
+
normalized = str(path or "").replace("\\", "/").strip("/")
|
|
64
|
+
if not normalized or normalized.startswith("../") or "/../" in f"/{normalized}/":
|
|
65
|
+
raise CdxError("Bundle contains an unsafe file path.")
|
|
66
|
+
return normalized
|
|
67
|
+
|
|
68
|
+
|
|
47
69
|
def _to_local_iso(value):
|
|
48
70
|
if not value:
|
|
49
71
|
return value
|
|
@@ -222,15 +244,65 @@ def create_session_service(options=None):
|
|
|
222
244
|
def _validate_new_session_name(name):
|
|
223
245
|
if not name:
|
|
224
246
|
raise CdxError("Session name is required")
|
|
247
|
+
if str(name) != str(name).strip():
|
|
248
|
+
raise CdxError("Session name cannot start or end with whitespace")
|
|
249
|
+
if len(str(name)) > MAX_SESSION_NAME_LENGTH:
|
|
250
|
+
raise CdxError(f"Session name is too long (max {MAX_SESSION_NAME_LENGTH} characters)")
|
|
251
|
+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in str(name)):
|
|
252
|
+
raise CdxError("Session name cannot contain control characters")
|
|
225
253
|
if name in RESERVED_SESSION_NAMES:
|
|
226
254
|
raise CdxError(f"Session name is reserved: {name}")
|
|
227
255
|
|
|
256
|
+
def _build_export_session_record(session):
|
|
257
|
+
return {
|
|
258
|
+
"name": session["name"],
|
|
259
|
+
"provider": session["provider"],
|
|
260
|
+
"createdAt": session.get("createdAt"),
|
|
261
|
+
"updatedAt": session.get("updatedAt"),
|
|
262
|
+
"lastLaunchedAt": session.get("lastLaunchedAt"),
|
|
263
|
+
"lastStatusAt": session.get("lastStatusAt"),
|
|
264
|
+
"lastStatus": session.get("lastStatus"),
|
|
265
|
+
"auth": session.get("auth"),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
def _collect_profile_files(session_root):
|
|
269
|
+
excluded_dirs = {"log", "tmp", "cache", "__pycache__", "shell_snapshots"}
|
|
270
|
+
files = []
|
|
271
|
+
if not os.path.isdir(session_root):
|
|
272
|
+
return files
|
|
273
|
+
for dirpath, dirnames, filenames in os.walk(session_root):
|
|
274
|
+
dirnames[:] = [name for name in dirnames if name not in excluded_dirs]
|
|
275
|
+
for filename in filenames:
|
|
276
|
+
full_path = os.path.join(dirpath, filename)
|
|
277
|
+
if not os.path.isfile(full_path):
|
|
278
|
+
continue
|
|
279
|
+
rel_path = os.path.relpath(full_path, session_root)
|
|
280
|
+
with open(full_path, "rb") as handle:
|
|
281
|
+
content = base64.b64encode(handle.read()).decode("ascii")
|
|
282
|
+
files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
|
|
283
|
+
return files
|
|
284
|
+
|
|
285
|
+
def _resolve_session_subset(session_names):
|
|
286
|
+
if not session_names:
|
|
287
|
+
return list_sessions()
|
|
288
|
+
by_name = {session["name"]: session for session in list_sessions()}
|
|
289
|
+
selected = []
|
|
290
|
+
for name in session_names:
|
|
291
|
+
session = by_name.get(name)
|
|
292
|
+
if not session:
|
|
293
|
+
raise CdxError(f"Unknown session: {name}")
|
|
294
|
+
selected.append(session)
|
|
295
|
+
return selected
|
|
296
|
+
|
|
228
297
|
def create_session(name, provider=DEFAULT_PROVIDER):
|
|
229
298
|
_validate_new_session_name(name)
|
|
230
299
|
normalized_provider = _normalize_provider(provider)
|
|
231
300
|
session_root = _get_session_root(name)
|
|
232
301
|
auth_home = _get_session_auth_home(name, normalized_provider)
|
|
233
|
-
|
|
302
|
+
_ensure_private_dir(base_dir)
|
|
303
|
+
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
304
|
+
_ensure_private_dir(session_root)
|
|
305
|
+
_ensure_private_dir(auth_home)
|
|
234
306
|
now = _local_now_iso()
|
|
235
307
|
session = {
|
|
236
308
|
"name": name,
|
|
@@ -514,7 +586,6 @@ def create_session_service(options=None):
|
|
|
514
586
|
rows.append({
|
|
515
587
|
"session_name": s["name"],
|
|
516
588
|
"provider": s["provider"],
|
|
517
|
-
"auth_home": s.get("authHome") or _get_session_auth_home(s["name"], s["provider"]),
|
|
518
589
|
"remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
|
|
519
590
|
"remaining_week_pct": status.get("remaining_week_pct") if status else None,
|
|
520
591
|
"credits": status.get("credits") if status else None,
|
|
@@ -543,6 +614,116 @@ def create_session_service(options=None):
|
|
|
543
614
|
def get_session_root(name):
|
|
544
615
|
return _get_session_root(name)
|
|
545
616
|
|
|
617
|
+
def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False):
|
|
618
|
+
if not file_path:
|
|
619
|
+
raise CdxError("Export path is required.")
|
|
620
|
+
if os.path.exists(file_path) and not force:
|
|
621
|
+
raise CdxError(f"Export path already exists: {file_path}")
|
|
622
|
+
|
|
623
|
+
sessions = _resolve_session_subset(session_names)
|
|
624
|
+
payload = {
|
|
625
|
+
"schema_version": 1,
|
|
626
|
+
"created_at": _local_now_iso(),
|
|
627
|
+
"include_auth": bool(include_auth),
|
|
628
|
+
"sessions": [],
|
|
629
|
+
"states": {},
|
|
630
|
+
"profiles": {},
|
|
631
|
+
}
|
|
632
|
+
for session in sessions:
|
|
633
|
+
payload["sessions"].append(_build_export_session_record(session))
|
|
634
|
+
state = store["read_session_state"](session["name"])
|
|
635
|
+
if state is not None:
|
|
636
|
+
payload["states"][session["name"]] = state
|
|
637
|
+
if include_auth:
|
|
638
|
+
session_root = session.get("sessionRoot") or _get_session_root(session["name"])
|
|
639
|
+
payload["profiles"][session["name"]] = _collect_profile_files(session_root)
|
|
640
|
+
|
|
641
|
+
bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
|
|
642
|
+
_ensure_private_dir(os.path.dirname(os.path.abspath(file_path)) or ".")
|
|
643
|
+
with open(file_path, "wb") as handle:
|
|
644
|
+
handle.write(bundle_bytes)
|
|
645
|
+
if sys.platform != "win32":
|
|
646
|
+
try:
|
|
647
|
+
os.chmod(file_path, 0o600)
|
|
648
|
+
except OSError:
|
|
649
|
+
pass
|
|
650
|
+
return {
|
|
651
|
+
"path": file_path,
|
|
652
|
+
"include_auth": include_auth,
|
|
653
|
+
"session_names": [session["name"] for session in sessions],
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
def import_bundle(file_path, passphrase=None, session_names=None, force=False):
|
|
657
|
+
if not file_path or not os.path.isfile(file_path):
|
|
658
|
+
raise CdxError(f"Bundle file not found: {file_path}")
|
|
659
|
+
with open(file_path, "rb") as handle:
|
|
660
|
+
decoded = decode_bundle(handle.read(), passphrase=passphrase)
|
|
661
|
+
payload = decoded["payload"]
|
|
662
|
+
imported_sessions = payload.get("sessions") or []
|
|
663
|
+
if payload.get("schema_version") != 1:
|
|
664
|
+
raise CdxError("Unsupported bundle payload schema version.")
|
|
665
|
+
|
|
666
|
+
selected_names = set(session_names or [])
|
|
667
|
+
if selected_names:
|
|
668
|
+
imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
|
|
669
|
+
missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
|
|
670
|
+
if missing_names:
|
|
671
|
+
raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
|
|
672
|
+
names = [item["name"] for item in imported_sessions]
|
|
673
|
+
|
|
674
|
+
existing = {session["name"] for session in list_sessions()}
|
|
675
|
+
conflicts = [name for name in names if name in existing]
|
|
676
|
+
if conflicts and not force:
|
|
677
|
+
raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
|
|
678
|
+
|
|
679
|
+
for session_payload in imported_sessions:
|
|
680
|
+
name = session_payload["name"]
|
|
681
|
+
_validate_new_session_name(name)
|
|
682
|
+
provider = _normalize_provider(session_payload["provider"])
|
|
683
|
+
if name in existing:
|
|
684
|
+
remove_session(name)
|
|
685
|
+
|
|
686
|
+
session_root = _get_session_root(name)
|
|
687
|
+
auth_home = _get_session_auth_home(name, provider)
|
|
688
|
+
_ensure_private_dir(base_dir)
|
|
689
|
+
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
690
|
+
_ensure_private_dir(session_root)
|
|
691
|
+
_ensure_private_dir(auth_home)
|
|
692
|
+
|
|
693
|
+
session_record = {
|
|
694
|
+
**session_payload,
|
|
695
|
+
"provider": provider,
|
|
696
|
+
"sessionRoot": session_root,
|
|
697
|
+
"authHome": auth_home,
|
|
698
|
+
}
|
|
699
|
+
store["replace_session"](name, session_record)
|
|
700
|
+
|
|
701
|
+
state = (payload.get("states") or {}).get(name)
|
|
702
|
+
if state is not None:
|
|
703
|
+
store["write_session_state"](name, state)
|
|
704
|
+
|
|
705
|
+
for item in (payload.get("profiles") or {}).get(name, []):
|
|
706
|
+
rel_path = _safe_relpath(item.get("path"))
|
|
707
|
+
try:
|
|
708
|
+
content = base64.b64decode(item.get("data_b64", "").encode("ascii"))
|
|
709
|
+
except (AttributeError, ValueError, UnicodeEncodeError) as error:
|
|
710
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
|
|
711
|
+
dest_path = os.path.join(session_root, rel_path)
|
|
712
|
+
_ensure_private_dir(os.path.dirname(dest_path))
|
|
713
|
+
with open(dest_path, "wb") as handle:
|
|
714
|
+
handle.write(content)
|
|
715
|
+
if sys.platform != "win32":
|
|
716
|
+
try:
|
|
717
|
+
os.chmod(dest_path, 0o600)
|
|
718
|
+
except OSError:
|
|
719
|
+
pass
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
"path": file_path,
|
|
723
|
+
"session_names": names,
|
|
724
|
+
"include_auth": bool(decoded["meta"].get("include_auth")),
|
|
725
|
+
}
|
|
726
|
+
|
|
546
727
|
return {
|
|
547
728
|
"create_session": create_session,
|
|
548
729
|
"remove_session": remove_session,
|
|
@@ -558,6 +739,8 @@ def create_session_service(options=None):
|
|
|
558
739
|
"format_list_rows": format_list_rows,
|
|
559
740
|
"get_session_auth_home": get_session_auth_home,
|
|
560
741
|
"get_session_root": get_session_root,
|
|
742
|
+
"export_bundle": export_bundle,
|
|
743
|
+
"import_bundle": import_bundle,
|
|
561
744
|
"base_dir": base_dir,
|
|
562
745
|
"normalize_provider": _normalize_provider,
|
|
563
746
|
}
|
package/src/session_store.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import sys
|
|
3
4
|
import tempfile
|
|
4
5
|
from contextlib import contextmanager
|
|
5
6
|
from pathlib import Path
|
|
@@ -9,6 +10,16 @@ from .errors import CdxError
|
|
|
9
10
|
|
|
10
11
|
def _ensure_dir(path):
|
|
11
12
|
Path(path).mkdir(parents=True, exist_ok=True)
|
|
13
|
+
_restrict_dir_permissions(path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _restrict_dir_permissions(path):
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
return
|
|
19
|
+
try:
|
|
20
|
+
os.chmod(path, 0o700)
|
|
21
|
+
except OSError:
|
|
22
|
+
pass
|
|
12
23
|
|
|
13
24
|
|
|
14
25
|
def _read_json(file_path, fallback):
|
package/src/status_source.py
CHANGED
|
@@ -119,7 +119,9 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
|
|
|
119
119
|
r"^To continue this session\b", r"^╰",
|
|
120
120
|
]],
|
|
121
121
|
context_pattern=re.compile(
|
|
122
|
-
r"^\s
|
|
122
|
+
r"^\s*$"
|
|
123
|
+
r"|^\s*[│|](?:\s|[│|])*$"
|
|
124
|
+
r"|^\s*(?:[│|]\s*)?(?:╭|Visit\b|information\b|Model:|Directory:|Permissions:|Agents\.md:|Account:|Collaboration mode:|Session:)",
|
|
123
125
|
re.I,
|
|
124
126
|
),
|
|
125
127
|
context_stop_patterns=[
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
UPDATE_CHECK_TTL_SECONDS = 12 * 60 * 60
|
|
9
|
+
LATEST_RELEASE_URL = "https://api.github.com/repos/AlexAgo83/cdx-manager/releases/latest"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_version(value):
|
|
13
|
+
raw = str(value or "").strip().lstrip("v")
|
|
14
|
+
parts = raw.split(".")
|
|
15
|
+
if len(parts) != 3:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return tuple(int(part) for part in parts)
|
|
19
|
+
except ValueError:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_newer_version(current_version, latest_version):
|
|
24
|
+
current = _parse_version(current_version)
|
|
25
|
+
latest = _parse_version(latest_version)
|
|
26
|
+
if not current or not latest:
|
|
27
|
+
return False
|
|
28
|
+
return latest > current
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cache_path(base_dir):
|
|
32
|
+
return os.path.join(base_dir, "state", "update-check.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_cache(path):
|
|
36
|
+
try:
|
|
37
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
38
|
+
return json.load(handle)
|
|
39
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _write_cache(path, payload):
|
|
44
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
45
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
46
|
+
json.dump(payload, handle, indent=2)
|
|
47
|
+
handle.write("\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fetch_latest_release():
|
|
51
|
+
request = urllib.request.Request(
|
|
52
|
+
LATEST_RELEASE_URL,
|
|
53
|
+
headers={
|
|
54
|
+
"Accept": "application/vnd.github+json",
|
|
55
|
+
"User-Agent": "cdx-manager-update-check",
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
59
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
60
|
+
return {
|
|
61
|
+
"latest_version": str(payload.get("tag_name") or "").lstrip("v"),
|
|
62
|
+
"url": payload.get("html_url") or payload.get("url"),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_for_update(base_dir, current_version, env=None, now_fn=None):
|
|
67
|
+
env = env or os.environ
|
|
68
|
+
now_fn = now_fn or (lambda: datetime.now(timezone.utc).timestamp())
|
|
69
|
+
if env.get("CDX_DISABLE_UPDATE_CHECK") in {"1", "true", "TRUE", "yes", "YES"}:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
path = _cache_path(base_dir)
|
|
73
|
+
now_ts = float(now_fn())
|
|
74
|
+
cached = _read_cache(path) or {}
|
|
75
|
+
checked_at = cached.get("checked_at")
|
|
76
|
+
if isinstance(checked_at, (int, float)) and (now_ts - checked_at) < UPDATE_CHECK_TTL_SECONDS:
|
|
77
|
+
latest_version = cached.get("latest_version")
|
|
78
|
+
if _is_newer_version(current_version, latest_version):
|
|
79
|
+
return {
|
|
80
|
+
"latest_version": latest_version,
|
|
81
|
+
"url": cached.get("url"),
|
|
82
|
+
"cached": True,
|
|
83
|
+
}
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
latest = _fetch_latest_release()
|
|
88
|
+
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
payload = {
|
|
92
|
+
"checked_at": now_ts,
|
|
93
|
+
"latest_version": latest.get("latest_version"),
|
|
94
|
+
"url": latest.get("url"),
|
|
95
|
+
}
|
|
96
|
+
try:
|
|
97
|
+
_write_cache(path, payload)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
if _is_newer_version(current_version, latest.get("latest_version")):
|
|
102
|
+
return {
|
|
103
|
+
"latest_version": latest.get("latest_version"),
|
|
104
|
+
"url": latest.get("url"),
|
|
105
|
+
"cached": False,
|
|
106
|
+
}
|
|
107
|
+
return None
|