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.
@@ -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
- os.makedirs(auth_home, exist_ok=True)
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
  }
@@ -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):
@@ -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*$|^\s*(?:[│|]\s*)?(?:╭|Visit\b|information\b|Model:|Directory:|Permissions:|Agents\.md:|Account:|Collaboration mode:|Session:)",
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