cdx-manager 0.2.1

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,290 @@
1
+ import json
2
+ import os
3
+ import signal
4
+ import shlex
5
+ import subprocess
6
+ from datetime import datetime, timezone
7
+
8
+ from .errors import CdxError
9
+
10
+
11
+ LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
12
+
13
+
14
+ def _get_auth_home(session):
15
+ return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
16
+
17
+
18
+ def _get_launch_transcript_path(session):
19
+ return os.path.join(_get_auth_home(session), "log", "cdx-session.log")
20
+
21
+
22
+ def _get_launch_transcript_dir(session):
23
+ return os.path.join(_get_auth_home(session), "log")
24
+
25
+
26
+ def _build_launch_transcript_path(session):
27
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
28
+ return os.path.join(
29
+ _get_launch_transcript_dir(session),
30
+ f"cdx-session-{stamp}-{os.getpid()}.log",
31
+ )
32
+
33
+
34
+ def _list_launch_transcript_paths(session, glob_fn=None):
35
+ import glob
36
+
37
+ glob_fn = glob_fn or glob.glob
38
+ log_dir = _get_launch_transcript_dir(session)
39
+ if not os.path.isdir(log_dir):
40
+ return []
41
+ paths = set(glob_fn(os.path.join(log_dir, "cdx-session*.log")))
42
+ legacy = _get_launch_transcript_path(session)
43
+ if os.path.exists(legacy):
44
+ paths.add(legacy)
45
+ return sorted(paths)
46
+
47
+
48
+ def _rotate_log_if_needed(log_path):
49
+ try:
50
+ if os.path.getsize(log_path) >= LOG_ROTATE_BYTES:
51
+ open(log_path, "w").close()
52
+ except OSError:
53
+ pass
54
+
55
+
56
+ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=None):
57
+ if not capture_transcript:
58
+ return spec
59
+ env = env or os.environ
60
+ script_bin = env.get("CDX_SCRIPT_BIN", "script")
61
+ script_args = env.get("CDX_SCRIPT_ARGS")
62
+ transcript_path = _build_launch_transcript_path(session)
63
+ os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
64
+ _rotate_log_if_needed(transcript_path)
65
+ if script_args:
66
+ args = shlex.split(script_args)
67
+ if "{transcript}" in args:
68
+ args = [transcript_path if arg == "{transcript}" else arg for arg in args]
69
+ else:
70
+ args = args + [transcript_path]
71
+ args = args + [spec["command"]] + spec["args"]
72
+ else:
73
+ args = ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
74
+ return {
75
+ "command": script_bin,
76
+ "args": args,
77
+ "options": spec["options"],
78
+ "label": spec["label"],
79
+ "fallback": spec,
80
+ "transcript_path": transcript_path,
81
+ }
82
+
83
+
84
+ def _build_launch_spec(session, cwd=None, env_override=None):
85
+ cwd = cwd or os.getcwd()
86
+ env_override = env_override or {}
87
+ env = {**os.environ, **env_override}
88
+ if session["provider"] == "claude":
89
+ return {
90
+ "command": "claude",
91
+ "args": ["--name", session["name"]],
92
+ "options": {
93
+ "cwd": cwd,
94
+ "env": {**env, "HOME": _get_auth_home(session)},
95
+ },
96
+ "label": "claude",
97
+ }
98
+ return _wrap_launch_with_transcript(session, {
99
+ "command": "codex",
100
+ "args": ["--no-alt-screen", "--cd", cwd],
101
+ "options": {
102
+ "env": {**env, "CODEX_HOME": _get_auth_home(session)},
103
+ },
104
+ "label": "codex",
105
+ }, env=env)
106
+
107
+
108
+ def _build_login_status_spec(session, env_override=None):
109
+ env = {**os.environ, **(env_override or {})}
110
+ if session["provider"] == "claude":
111
+ env["HOME"] = _get_auth_home(session)
112
+
113
+ def parser(output):
114
+ try:
115
+ return bool(json.loads(output or "{}").get("loggedIn"))
116
+ except (json.JSONDecodeError, AttributeError):
117
+ return False
118
+
119
+ return {"command": "claude", "args": ["auth", "status"], "env": env,
120
+ "parser": parser, "label": "claude auth status"}
121
+ env["CODEX_HOME"] = _get_auth_home(session)
122
+
123
+ def parser(output):
124
+ if "Not logged in" in (output or ""):
125
+ return False
126
+ return "Logged in" in (output or "")
127
+
128
+ return {"command": "codex", "args": ["login", "status"], "env": env,
129
+ "parser": parser, "label": "codex login status"}
130
+
131
+
132
+ def _build_auth_action_spec(session, action, cwd=None, env_override=None):
133
+ cwd = cwd or os.getcwd()
134
+ env = {**os.environ, **(env_override or {})}
135
+ if session["provider"] == "claude":
136
+ env["HOME"] = _get_auth_home(session)
137
+ return {"command": "claude", "args": ["auth", action],
138
+ "options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
139
+ env["CODEX_HOME"] = _get_auth_home(session)
140
+ return {"command": "codex", "args": [action],
141
+ "options": {"cwd": cwd, "env": env}, "label": f"codex {action}"}
142
+
143
+
144
+ def _probe_provider_auth(session, spawn_sync=None, env_override=None):
145
+ spawn_sync = spawn_sync or subprocess.run
146
+ spec = _build_login_status_spec(session, env_override)
147
+ if spawn_sync is subprocess.run:
148
+ result = subprocess.run(
149
+ [spec["command"]] + spec["args"],
150
+ env=spec["env"],
151
+ capture_output=True, text=True,
152
+ )
153
+ output = (result.stdout or "") + (result.stderr or "")
154
+ else:
155
+ result = spawn_sync(spec["command"], spec["args"], spec)
156
+ error = result.get("error") if isinstance(result, dict) else getattr(result, "error", None)
157
+ if error:
158
+ raise CdxError(
159
+ f"Failed to check login status for {session['name']}: {getattr(error, 'message', str(error))}"
160
+ )
161
+ stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
162
+ stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
163
+ output = (stdout or "") + (stderr or "")
164
+ return spec["parser"](output)
165
+
166
+
167
+ def _signal_exit_code(sig):
168
+ return {signal.SIGHUP: 129, signal.SIGINT: 130, signal.SIGTERM: 143}.get(sig, 1)
169
+
170
+
171
+ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
172
+ env_override=None, signal_emitter=None):
173
+ spawn = spawn or subprocess.Popen
174
+ spec = (
175
+ _build_launch_spec(session, cwd=cwd, env_override=env_override)
176
+ if action == "launch"
177
+ else _build_auth_action_spec(session, action, cwd=cwd, env_override=env_override)
178
+ )
179
+ def start_child(current_spec):
180
+ return spawn(
181
+ [current_spec["command"]] + current_spec["args"],
182
+ **{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
183
+ )
184
+
185
+ try:
186
+ child = start_child(spec)
187
+ except FileNotFoundError as error:
188
+ spec = _fallback_launch_spec_or_raise(spec, error)
189
+ child = start_child(spec)
190
+
191
+ forwarded_signal = [None]
192
+ handlers = []
193
+
194
+ def forward(sig, _frame=None):
195
+ forwarded_signal[0] = sig
196
+ try:
197
+ if hasattr(child, "send_signal"):
198
+ child.send_signal(sig)
199
+ elif hasattr(child, "kill"):
200
+ child.kill(sig)
201
+ except Exception:
202
+ pass
203
+
204
+ original_handlers = {}
205
+ use_emitter = hasattr(signal_emitter, "on") and hasattr(signal_emitter, "removeListener")
206
+
207
+ if use_emitter:
208
+ for sig in ("SIGINT", "SIGTERM", "SIGHUP"):
209
+ handler = lambda current_sig=sig: forward(getattr(signal, current_sig), None)
210
+ handlers.append((sig, handler))
211
+ signal_emitter.on(sig, handler)
212
+ else:
213
+ for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
214
+ try:
215
+ original_handlers[sig] = signal.signal(sig, forward)
216
+ except (OSError, ValueError):
217
+ pass
218
+
219
+ try:
220
+ child.wait()
221
+ if forwarded_signal[0] is None and child.returncode != 0 and _should_retry_without_transcript(spec):
222
+ spec = _fallback_launch_spec_or_raise(spec)
223
+ child = start_child(spec)
224
+ child.wait()
225
+ finally:
226
+ if use_emitter:
227
+ for sig, handler in handlers:
228
+ try:
229
+ signal_emitter.removeListener(sig, handler)
230
+ except Exception:
231
+ pass
232
+ else:
233
+ for sig, handler in original_handlers.items():
234
+ try:
235
+ signal.signal(sig, handler)
236
+ except (OSError, ValueError):
237
+ pass
238
+
239
+ if forwarded_signal[0] is not None:
240
+ raise CdxError(
241
+ f"{spec['label']} interrupted by {forwarded_signal[0].name} for session {session['name']}",
242
+ _signal_exit_code(forwarded_signal[0]),
243
+ )
244
+ if child.returncode != 0:
245
+ raise CdxError(
246
+ f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
247
+ )
248
+
249
+
250
+ def _fallback_launch_spec_or_raise(spec, original_error=None):
251
+ fallback = spec.get("fallback")
252
+ if not fallback:
253
+ if original_error is not None:
254
+ raise original_error
255
+ raise CdxError(f"{spec['label']} cannot run without a fallback")
256
+ return {**fallback, "label": f"{fallback['label']} (without transcript)"}
257
+
258
+
259
+ def _should_retry_without_transcript(spec):
260
+ if not spec.get("fallback"):
261
+ return False
262
+ transcript_path = spec.get("transcript_path")
263
+ if not transcript_path:
264
+ return False
265
+ try:
266
+ return not os.path.exists(transcript_path) or os.path.getsize(transcript_path) == 0
267
+ except OSError:
268
+ return True
269
+
270
+
271
+ def _ensure_session_authentication(session, service, spawn=None, spawn_sync=None,
272
+ stdin_is_tty=True, env_override=None, behavior="launch",
273
+ signal_emitter=None):
274
+ is_authenticated = _probe_provider_auth(session, spawn_sync=spawn_sync, env_override=env_override)
275
+ if is_authenticated:
276
+ return {"authenticated": True, "checked": True}
277
+ if behavior == "probe-only":
278
+ return {"authenticated": False, "checked": True}
279
+ if behavior == "launch":
280
+ raise CdxError(
281
+ f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
282
+ )
283
+ if not stdin_is_tty:
284
+ raise CdxError(
285
+ f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
286
+ )
287
+ _run_interactive_provider_command(
288
+ session, "login", spawn=spawn, env_override=env_override, signal_emitter=signal_emitter
289
+ )
290
+ return {"authenticated": True, "checked": True, "bootstrapped": True}
package/src/repair.py ADDED
@@ -0,0 +1,121 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+
5
+ from .health import collect_health_report
6
+
7
+
8
+ def repair_health(service, base_dir, env=None, dry_run=True, force=False):
9
+ report = collect_health_report(service, base_dir, env)
10
+ actions = []
11
+ for issue in report["issues"]:
12
+ code = issue["code"]
13
+ detail = issue.get("detail")
14
+ if code == "missing_state":
15
+ name = _session_name_from_state_path(detail)
16
+ actions.append(_action(
17
+ "recreate_state",
18
+ f"recreate missing state for {name}",
19
+ detail,
20
+ _apply_recreate_state(service, name) if not dry_run else None,
21
+ ))
22
+ elif code == "quarantine_profile":
23
+ actions.append(_action(
24
+ "remove_quarantine",
25
+ f"remove quarantine profile {os.path.basename(detail)}",
26
+ detail,
27
+ _apply_remove_path(detail) if not dry_run else None,
28
+ ))
29
+ elif code == "orphan_profile":
30
+ if force:
31
+ actions.append(_action(
32
+ "quarantine_orphan",
33
+ f"move orphan profile {os.path.basename(detail)} to quarantine",
34
+ detail,
35
+ _apply_quarantine_orphan(detail) if not dry_run else None,
36
+ ))
37
+ else:
38
+ actions.append(_action(
39
+ "skip_orphan",
40
+ f"orphan profile needs --force: {os.path.basename(detail)}",
41
+ detail,
42
+ "skipped",
43
+ ))
44
+ return {
45
+ "dry_run": dry_run,
46
+ "force": force,
47
+ "actions": actions,
48
+ "summary": {
49
+ "planned": len(actions),
50
+ "applied": sum(1 for action in actions if action["status"] == "applied"),
51
+ "skipped": sum(1 for action in actions if action["status"] == "skipped"),
52
+ },
53
+ }
54
+
55
+
56
+ def _session_name_from_state_path(path):
57
+ if not path:
58
+ return None
59
+ filename = os.path.basename(path)
60
+ if filename.endswith(".json"):
61
+ filename = filename[:-5]
62
+ from urllib.parse import unquote
63
+ return unquote(filename)
64
+
65
+
66
+ def _apply_recreate_state(service, name):
67
+ service["ensure_session_state"](name)
68
+ return "applied"
69
+
70
+
71
+ def _apply_remove_path(path):
72
+ shutil.rmtree(path)
73
+ return "applied"
74
+
75
+
76
+ def _apply_quarantine_orphan(path):
77
+ parent = os.path.dirname(path)
78
+ dest = os.path.join(parent, f".{os.path.basename(path)}.remove.orphan")
79
+ suffix = 1
80
+ candidate = dest
81
+ while os.path.exists(candidate):
82
+ suffix += 1
83
+ candidate = f"{dest}.{suffix}"
84
+ os.rename(path, candidate)
85
+ return "applied"
86
+
87
+
88
+ def _action(code, message, path, result):
89
+ if result is None:
90
+ status = "planned"
91
+ elif result == "skipped":
92
+ status = "skipped"
93
+ else:
94
+ status = "applied"
95
+ return {"status": status, "code": code, "message": message, "path": path}
96
+
97
+
98
+ def format_repair_report(report, use_color=False):
99
+ from .cli_render import _pad_table, _style
100
+
101
+ rows = [["STATUS", "ACTION", "MESSAGE"]]
102
+ for action in report["actions"]:
103
+ status = action["status"]
104
+ style = "32" if status == "applied" else "33" if status == "planned" else "2"
105
+ rows.append([_style(status.upper(), style, use_color), action["code"], action["message"]])
106
+ if len(rows) == 1:
107
+ rows.append([_style("OK", "32", use_color), "-", "nothing to repair"])
108
+ summary = report["summary"]
109
+ return "\n".join([
110
+ _pad_table(rows),
111
+ "",
112
+ _style(
113
+ f"Summary: {summary['planned']} planned, {summary['applied']} applied, {summary['skipped']} skipped.",
114
+ "1",
115
+ use_color,
116
+ ),
117
+ ])
118
+
119
+
120
+ def repair_json(report):
121
+ return json.dumps(report, indent=2)