cdx-manager 0.12.4 → 0.15.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.
@@ -0,0 +1,723 @@
1
+ """Maintenance domain commands: doctor, repair, clean, disk, update.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from datetime import datetime
14
+
15
+ from ..cli_args import DISK_USAGE, REPAIR_USAGE, _parse_doctor_args, _parse_flag_args, _parse_json_flag, _parse_update_args
16
+ from ..cli_helpers import _format_bytes, _json_success, _resolve_confirmation, _write_json
17
+ from ..cli_render import _dim, _info, _pad_table, _style, _success, _warn
18
+ from ..errors import CdxError
19
+ from ..health import collect_health_report, filter_health_report, format_health_report
20
+ from ..provider_runtime import _list_launch_transcript_paths
21
+ from ..repair import format_repair_report, repair_health
22
+ from ..update_all import apply_update_all_plan, collect_update_all_plan
23
+ from ..update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
24
+ from ..update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
25
+
26
+
27
+ def _format_update_all(plan, use_color=False):
28
+ labels = {
29
+ "up_to_date": _success("OK", use_color),
30
+ "update_available": _warn("UPDATE", use_color),
31
+ "not_installed": _warn("MISSING", use_color),
32
+ "check_with_native_updater": _info("CHECK", use_color),
33
+ }
34
+ rows = [[_style("TOOL", "1", use_color), _style("CURRENT", "1", use_color), _style("LATEST", "1", use_color), _style("STATUS", "1", use_color)]]
35
+ for item in plan["items"]:
36
+ rows.append([_info(item["name"], use_color), item.get("version") or _dim("-", use_color), item.get("latest_version") or _dim("-", use_color), labels.get(item["status"], item["status"].replace("_", " "))])
37
+ lines = [_style("Workstation update", "1", use_color), _dim("Inventory only — nothing has changed.", use_color), "", _pad_table(rows)]
38
+ missing_rtk = plan["setup"]["rtk_missing_sessions"]
39
+ setup = [f"RTK: enable for {', '.join(missing_rtk)}" if missing_rtk else "RTK: configured for every session"]
40
+ setup.extend(f"Ponytail {plugin['session']}: {labels.get(plugin['status'], plugin['status'].replace('_', ' '))}" for plugin in plan["setup"]["ponytail"])
41
+ lines.extend(["", _style("Session setup", "1", use_color), *setup])
42
+ if plan["steps"]:
43
+ lines.extend(["", _warn(f"{len(plan['steps'])} action(s) ready", use_color), _dim("Confirm to update only the tools listed above.", use_color)])
44
+ else:
45
+ lines.extend(["", _success("Everything managed by CDX is current and configured.", use_color)])
46
+ return "\n".join(lines)
47
+
48
+ def _format_update_all_result(result, use_color=False):
49
+ if result.get("skipped"):
50
+ return _warn(f"skipped: {result['name']} (blocked by {result['blocked_by']})", use_color)
51
+ if result.get("returncode") == 0:
52
+ return _success(f"done: {result['name']}", use_color)
53
+ detail = str(result.get("stderr") or result.get("stdout") or "No error output.").strip().replace("\n", " ")
54
+ detail = detail[-500:]
55
+ command = shlex.join(result.get("command") or [])
56
+ suffix = f"\n {_dim(command, use_color)}" if command else ""
57
+ label = _warn(f"failed: {result['name']} (exit {result.get('returncode')})", use_color)
58
+ return f"{label}\n {detail}{suffix}"
59
+
60
+ def _confirm_profile_cleanup(action):
61
+ answer = input(f"Delete matching profile {action} candidates? [y/N] ")
62
+ return answer.strip().lower() in ("y", "yes")
63
+
64
+ def _confirm_log_cleanup(target):
65
+ answer = input(f"Clear launch transcript logs for {target}? [y/N] ")
66
+ return answer.strip().lower() in ("y", "yes")
67
+
68
+ def _directory_size_bytes(path, runner=None):
69
+ if not os.path.exists(path):
70
+ raise CdxError(f"CDX home does not exist: {path}")
71
+ runner = runner or subprocess.check_output
72
+ try:
73
+ output = runner(["du", "-sk", path], text=True, stderr=subprocess.DEVNULL)
74
+ return int(str(output).split()[0]) * 1024
75
+ except (OSError, subprocess.CalledProcessError, ValueError, IndexError):
76
+ total = 0
77
+ for root, _, files in os.walk(path):
78
+ for name in files:
79
+ try:
80
+ total += os.path.getsize(os.path.join(root, name))
81
+ except OSError:
82
+ pass
83
+ return total
84
+
85
+ def _directory_child_sizes(path, runner=None, progress=None):
86
+ rows = []
87
+ try:
88
+ names = os.listdir(path)
89
+ except OSError:
90
+ return rows
91
+ children = [(name, os.path.join(path, name)) for name in names if os.path.isdir(os.path.join(path, name))]
92
+ for index, (name, child_path) in enumerate(children, start=1):
93
+ if progress:
94
+ progress("profile", name, index, len(children))
95
+ rows.append({
96
+ "name": name,
97
+ "path": child_path,
98
+ "bytes": _directory_size_bytes(child_path, runner=runner),
99
+ })
100
+ rows.sort(key=lambda row: row["bytes"], reverse=True)
101
+ for row in rows:
102
+ row["size"] = _format_bytes(row["bytes"])
103
+ return rows
104
+
105
+ def _parse_days(value, usage):
106
+ match = re.fullmatch(r"(\d+)(?:d)?", str(value or "").strip())
107
+ if not match or int(match.group(1)) <= 0:
108
+ raise CdxError(usage)
109
+ return int(match.group(1))
110
+
111
+ def _candidate(profile, kind, path, bytes_used, reason, risk="safe", evidence=None):
112
+ return {
113
+ "profile": profile,
114
+ "kind": kind,
115
+ "path": path,
116
+ "bytes": bytes_used,
117
+ "size": _format_bytes(bytes_used),
118
+ "reason": reason,
119
+ "risk": risk,
120
+ "evidence": evidence or {},
121
+ }
122
+
123
+ def _iter_profile_dirs(base_dir):
124
+ profiles_dir = os.path.join(base_dir, "profiles")
125
+ try:
126
+ names = sorted(os.listdir(profiles_dir))
127
+ except OSError:
128
+ return
129
+ for name in names:
130
+ path = os.path.join(profiles_dir, name)
131
+ if os.path.isdir(path):
132
+ yield name, path
133
+
134
+ def _collect_old_logs(profile_name, profile_path, days, now=None):
135
+ cutoff = (time.time() if now is None else now) - (days * 86400)
136
+ paths = []
137
+ bytes_used = 0
138
+ latest_mtime = 0
139
+ for root, _, files in os.walk(profile_path):
140
+ if os.path.basename(root) != "log":
141
+ continue
142
+ for filename in files:
143
+ if not filename.endswith(".log"):
144
+ continue
145
+ path = os.path.join(root, filename)
146
+ try:
147
+ stat = os.stat(path)
148
+ except OSError:
149
+ continue
150
+ if stat.st_mtime >= cutoff:
151
+ continue
152
+ paths.append(path)
153
+ bytes_used += stat.st_size
154
+ latest_mtime = max(latest_mtime, stat.st_mtime)
155
+ if not paths:
156
+ return None
157
+ return _candidate(
158
+ profile_name,
159
+ f"old-logs-{days}d",
160
+ profile_path,
161
+ bytes_used,
162
+ f"{len(paths)} .log files older than {days}d",
163
+ risk="review",
164
+ evidence={
165
+ "days": days,
166
+ "file_count": len(paths),
167
+ "newest_old_log_mtime": datetime.fromtimestamp(latest_mtime).isoformat() if latest_mtime else None,
168
+ "sample_paths": paths[:5],
169
+ },
170
+ )
171
+
172
+ def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=None, now=None, progress=None):
173
+ candidates = []
174
+ profiles = list(_iter_profile_dirs(base_dir))
175
+ for index, (profile_name, profile_path) in enumerate(profiles, start=1):
176
+ if progress:
177
+ progress("candidates", profile_name, index, len(profiles))
178
+ tmp_dir = os.path.join(profile_path, ".tmp")
179
+ tmp_targets = [
180
+ ("tmp-marketplaces", os.path.join(tmp_dir, "marketplaces"), "temporary marketplace cache/staging"),
181
+ ]
182
+ try:
183
+ tmp_names = os.listdir(tmp_dir)
184
+ except OSError:
185
+ tmp_names = []
186
+ for name in tmp_names:
187
+ if name.startswith("plugins-clone-"):
188
+ tmp_targets.append(("tmp-plugin-clone", os.path.join(tmp_dir, name), "temporary plugin clone"))
189
+ elif name.startswith("plugins-backup-"):
190
+ tmp_targets.append(("tmp-plugin-backup", os.path.join(tmp_dir, name), "temporary plugin backup"))
191
+ for kind, path, reason in tmp_targets:
192
+ if not os.path.exists(path):
193
+ continue
194
+ bytes_used = _directory_size_bytes(path, runner=runner)
195
+ if bytes_used <= 0:
196
+ continue
197
+ try:
198
+ mtime = datetime.fromtimestamp(os.path.getmtime(path)).isoformat()
199
+ except OSError:
200
+ mtime = None
201
+ candidates.append(_candidate(
202
+ profile_name,
203
+ kind,
204
+ path,
205
+ bytes_used,
206
+ reason,
207
+ evidence={"modified_at": mtime},
208
+ ))
209
+ old_logs = _collect_old_logs(profile_name, profile_path, old_log_days, now=now)
210
+ if old_logs and old_logs["bytes"] > 0:
211
+ candidates.append(old_logs)
212
+ candidates.sort(key=lambda item: item["bytes"], reverse=True)
213
+ return candidates
214
+
215
+ def _format_cleanup_candidates(candidates, use_color=False):
216
+ if not candidates:
217
+ return _dim("No cleanup candidates found.", use_color)
218
+ reclaimable = sum(item["bytes"] for item in candidates)
219
+ lines = [
220
+ _style("Cleanup candidates", "1", use_color),
221
+ f"{_style(_format_bytes(reclaimable), '33', use_color)} reclaimable across {len({item['profile'] for item in candidates})} profile(s)",
222
+ ]
223
+ by_profile = {}
224
+ for item in candidates:
225
+ by_profile.setdefault(item["profile"], []).append(item)
226
+ for profile, items in sorted(by_profile.items(), key=lambda row: sum(i["bytes"] for i in row[1]), reverse=True):
227
+ total = sum(item["bytes"] for item in items)
228
+ lines.extend(["", f"{_style(profile, '1', use_color)} {_style(_format_bytes(total), '33', use_color)}"])
229
+ rows = [[
230
+ _style("SIZE", "1", use_color),
231
+ _style("TYPE", "1", use_color),
232
+ _style("RISK", "1", use_color),
233
+ _style("EVIDENCE", "1", use_color),
234
+ ]]
235
+ for item in items:
236
+ risk_color = "32" if item["risk"] == "safe" else "33"
237
+ rows.append([
238
+ _style(item["size"], "36", use_color),
239
+ item["kind"],
240
+ _style(item["risk"], risk_color, use_color),
241
+ item["reason"],
242
+ ])
243
+ lines.append(_pad_table(rows))
244
+ return "\n".join(lines)
245
+
246
+ def _format_disk_report(payload, use_color=False):
247
+ title = "CDX profiles" if payload["target"] == "profiles" else "CDX home"
248
+ lines = [
249
+ _style(title, "1", use_color),
250
+ f"Path: {_dim(payload['path'], use_color)}",
251
+ f"Total: {_style(payload['size'], '36', use_color)}",
252
+ ]
253
+ children = payload.get("children") or []
254
+ if children:
255
+ reclaimable_by_profile = {}
256
+ for item in payload.get("candidates") or []:
257
+ reclaimable_by_profile[item["profile"]] = reclaimable_by_profile.get(item["profile"], 0) + item["bytes"]
258
+ headers = ["PROFILE", "SIZE", "SHARE"]
259
+ if "candidates" in payload:
260
+ headers.append("RECLAIMABLE")
261
+ rows = [[_style(header, "1", use_color) for header in headers]]
262
+ for child in children:
263
+ share = (child["bytes"] / payload["bytes"] * 100) if payload["bytes"] else 0
264
+ row = [
265
+ _style(child["name"], "1", use_color),
266
+ _style(child["size"], "36", use_color),
267
+ f"{share:.1f}%",
268
+ ]
269
+ if "candidates" in payload:
270
+ reclaimable = reclaimable_by_profile.get(child["name"], 0)
271
+ row.append(_style(_format_bytes(reclaimable), "33", use_color) if reclaimable else _dim("-", use_color))
272
+ rows.append(row)
273
+ lines.extend(["", _style(f"Profiles ({len(children)})", "1", use_color), _pad_table(rows)])
274
+ if "candidates" in payload:
275
+ lines.extend(["", _format_cleanup_candidates(payload["candidates"], use_color=use_color)])
276
+ return "\n".join(lines)
277
+
278
+ def _remove_path(path):
279
+ try:
280
+ if os.path.isdir(path):
281
+ size = _directory_size_bytes(path)
282
+ shutil.rmtree(path)
283
+ else:
284
+ size = os.path.getsize(path)
285
+ os.remove(path)
286
+ return size
287
+ except OSError as error:
288
+ raise CdxError(f"Failed to remove cleanup candidate {path}: {error}") from error
289
+
290
+ def _clean_profile_tmp(profile_path):
291
+ tmp_dir = os.path.join(profile_path, ".tmp")
292
+ targets = [os.path.join(tmp_dir, "marketplaces")]
293
+ try:
294
+ names = os.listdir(tmp_dir)
295
+ except OSError:
296
+ names = []
297
+ targets.extend(os.path.join(tmp_dir, name) for name in names if name.startswith(("plugins-clone-", "plugins-backup-")))
298
+ freed = 0
299
+ removed = []
300
+ for path in targets:
301
+ if os.path.exists(path):
302
+ size = _remove_path(path)
303
+ if size:
304
+ freed += size
305
+ removed.append(path)
306
+ return freed, removed
307
+
308
+ def _clean_profile_old_logs(profile_path, days, now=None):
309
+ cutoff = (time.time() if now is None else now) - (days * 86400)
310
+ freed = 0
311
+ removed = []
312
+ for root, _, files in os.walk(profile_path):
313
+ if os.path.basename(root) != "log":
314
+ continue
315
+ for filename in files:
316
+ if not filename.endswith(".log"):
317
+ continue
318
+ path = os.path.join(root, filename)
319
+ try:
320
+ stat = os.stat(path)
321
+ except OSError:
322
+ continue
323
+ if stat.st_mtime >= cutoff:
324
+ continue
325
+ try:
326
+ os.remove(path)
327
+ except OSError as error:
328
+ raise CdxError(f"Failed to remove old log {path}: {error}") from error
329
+ freed += stat.st_size
330
+ removed.append(path)
331
+ return freed, removed
332
+
333
+ def _handle_clean_profiles(args, ctx, json_flag):
334
+ usage = "Usage: cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]"
335
+ yes = "--yes" in args
336
+ args = [arg for arg in args if arg != "--yes"]
337
+ clean_tmp = "--tmp" in args
338
+ old_logs = None
339
+ if "--old-logs" in args:
340
+ index = args.index("--old-logs")
341
+ if index + 1 >= len(args):
342
+ raise CdxError(usage)
343
+ old_logs = _parse_days(args[index + 1], usage)
344
+ args = [arg for i, arg in enumerate(args) if i not in (index, index + 1)]
345
+ else:
346
+ old_log_equals = [arg for arg in args if arg.startswith("--old-logs=")]
347
+ if old_log_equals:
348
+ if len(old_log_equals) > 1:
349
+ raise CdxError(usage)
350
+ old_logs = _parse_days(old_log_equals[0].split("=", 1)[1], usage)
351
+ args = [arg for arg in args if arg != old_log_equals[0]]
352
+ args = [arg for arg in args if arg != "--tmp"]
353
+ if args or clean_tmp == (old_logs is not None):
354
+ raise CdxError(usage)
355
+
356
+ action = "temporary cache" if clean_tmp else f"logs older than {old_logs}d"
357
+ if not yes:
358
+ confirm_fn = ctx["options"].get("confirmProfileCleanup")
359
+ if confirm_fn:
360
+ confirmed = _resolve_confirmation(confirm_fn, action)
361
+ elif not ctx["stdin_is_tty"]:
362
+ raise CdxError("Profile cleanup requires an interactive terminal or --yes in non-interactive mode.")
363
+ else:
364
+ confirmed = _confirm_profile_cleanup(action)
365
+ if not confirmed:
366
+ if json_flag:
367
+ _write_json(ctx, _json_success("clean.profiles", "Cancelled.", cancelled=True, freed_bytes=0, freed_size="0 B", profiles=[]))
368
+ return 0
369
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
370
+ return 0
371
+
372
+ results = []
373
+ now = ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None
374
+ for profile_name, profile_path in _iter_profile_dirs(ctx["service"]["base_dir"]):
375
+ if clean_tmp:
376
+ freed, removed = _clean_profile_tmp(profile_path)
377
+ result_action = "tmp"
378
+ else:
379
+ freed, removed = _clean_profile_old_logs(profile_path, old_logs, now=now)
380
+ result_action = f"old-logs-{old_logs}d"
381
+ if not freed and not removed:
382
+ continue
383
+ results.append({
384
+ "profile": profile_name,
385
+ "action": result_action,
386
+ "freed_bytes": freed,
387
+ "freed_size": _format_bytes(freed),
388
+ "removed_count": len(removed),
389
+ "removed_paths": removed,
390
+ })
391
+ total = sum(item["freed_bytes"] for item in results)
392
+ if json_flag:
393
+ _write_json(ctx, _json_success("clean.profiles", f"Cleaned profiles ({_format_bytes(total)} freed)", cancelled=False, freed_bytes=total, freed_size=_format_bytes(total), profiles=results))
394
+ return 0
395
+ if not results:
396
+ ctx["out"](f"{_dim('No matching profile cleanup candidates found.', ctx['use_color'])}\n")
397
+ return 0
398
+ for item in results:
399
+ message = f"Cleaned {item['profile']} {item['action']} ({item['freed_size']} freed)"
400
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
401
+ ctx["out"](f"{_success(f'Total freed: {_format_bytes(total)}', ctx['use_color'])}\n")
402
+ return 0
403
+
404
+ def handle_disk(rest, ctx):
405
+ parsed = _parse_flag_args(rest, {
406
+ "--candidates": {"key": "candidates", "type": "bool", "default": False},
407
+ "--json": {"key": "json", "type": "bool", "default": False},
408
+ }, DISK_USAGE, positionals_key="targets", max_positionals=1)
409
+ target = parsed["targets"][0] if parsed["targets"] else "home"
410
+ if parsed["candidates"] and target != "profiles":
411
+ raise CdxError(DISK_USAGE)
412
+ if target == "home":
413
+ path = ctx["service"]["base_dir"]
414
+ elif target == "profiles":
415
+ path = os.path.join(ctx["service"]["base_dir"], "profiles")
416
+ else:
417
+ raise CdxError(DISK_USAGE)
418
+ stderr = ctx["options"].get("stderr", sys.stderr)
419
+ show_progress = not parsed["json"] and hasattr(stderr, "isatty") and stderr.isatty()
420
+
421
+ def progress(stage, name=None, index=None, total=None):
422
+ if not show_progress:
423
+ return
424
+ if stage == "total":
425
+ label = "CDX profiles" if target == "profiles" else "CDX home"
426
+ ctx["err"](f"Measuring {label} disk usage...\n")
427
+ elif stage == "profile":
428
+ ctx["err"](f"Measuring profile {name} ({index}/{total})...\n")
429
+ elif stage == "candidates":
430
+ ctx["err"](f"Scanning cleanup candidates in {name} ({index}/{total})...\n")
431
+
432
+ progress("total")
433
+ bytes_used = _directory_size_bytes(path, runner=ctx["options"].get("diskUsageRunner"))
434
+ payload = {
435
+ "target": target,
436
+ "path": path,
437
+ "bytes": bytes_used,
438
+ "size": _format_bytes(bytes_used),
439
+ }
440
+ if target == "profiles":
441
+ payload["children"] = _directory_child_sizes(path, runner=ctx["options"].get("diskUsageRunner"), progress=progress)
442
+ if parsed["candidates"]:
443
+ candidates = _collect_profile_cleanup_candidates(
444
+ ctx["service"]["base_dir"],
445
+ runner=ctx["options"].get("diskUsageRunner"),
446
+ now=ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None,
447
+ progress=progress,
448
+ )
449
+ payload["candidates"] = candidates
450
+ payload["reclaimable_bytes"] = sum(item["bytes"] for item in candidates)
451
+ payload["reclaimable_size"] = _format_bytes(payload["reclaimable_bytes"])
452
+ if parsed["json"]:
453
+ label = "CDX profiles" if target == "profiles" else "CDX home"
454
+ _write_json(ctx, _json_success("disk", f"Measured {label} disk usage: {payload['size']}", disk=payload))
455
+ return 0
456
+ ctx["out"](f"{_format_disk_report(payload, use_color=ctx['use_color'])}\n")
457
+ return 0
458
+
459
+ def handle_clean(rest, ctx):
460
+ json_flag, args = _parse_json_flag(rest)
461
+ profile_flags = ("--tmp", "--old-logs")
462
+ has_profile_flag = any(arg in profile_flags or arg.startswith("--old-logs=") for arg in args)
463
+ if args[:1] == ["profiles"] or has_profile_flag:
464
+ if args[:1] == ["profiles"]:
465
+ return _handle_clean_profiles(args[1:], ctx, json_flag)
466
+ return _handle_clean_profiles(args, ctx, json_flag)
467
+ yes = "--yes" in args
468
+ args = [arg for arg in args if arg != "--yes"]
469
+ service = ctx["service"]
470
+ if len(args) == 0:
471
+ targets = service["list_sessions"]()
472
+ elif len(args) == 1:
473
+ session = service["get_session"](args[0])
474
+ if not session:
475
+ raise CdxError(f"Unknown session: {args[0]}")
476
+ targets = [session]
477
+ else:
478
+ raise CdxError("Usage: cdx clean [name] [--yes] [--json]")
479
+
480
+ if not yes:
481
+ target = targets[0]["name"] if len(targets) == 1 else f"all {len(targets)} sessions"
482
+ confirm_fn = ctx["options"].get("confirmClean")
483
+ if confirm_fn:
484
+ confirmed = _resolve_confirmation(confirm_fn, target)
485
+ elif not ctx["stdin_is_tty"]:
486
+ raise CdxError("Log cleanup requires an interactive terminal or --yes in non-interactive mode.")
487
+ else:
488
+ confirmed = _confirm_log_cleanup(target)
489
+ if not confirmed:
490
+ if json_flag:
491
+ _write_json(ctx, _json_success("clean", "Cancelled.", cancelled=True, sessions=[]))
492
+ return 0
493
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
494
+ return 0
495
+
496
+ cleaned_sessions = []
497
+ for session in targets:
498
+ log_paths = _list_launch_transcript_paths(session)
499
+ if not log_paths:
500
+ message = f"{session['name']}: no log found"
501
+ cleaned_sessions.append({
502
+ "session_name": session["name"],
503
+ "cleared": False,
504
+ "files_cleared": 0,
505
+ "freed_kb": 0,
506
+ "message": message,
507
+ })
508
+ if json_flag:
509
+ continue
510
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
511
+ continue
512
+ total_size = 0
513
+ cleared = 0
514
+ for log_path in log_paths:
515
+ try:
516
+ total_size += os.path.getsize(log_path)
517
+ open(log_path, "w").close()
518
+ cleared += 1
519
+ except OSError:
520
+ continue
521
+ if cleared:
522
+ message = (
523
+ f"Cleared {session['name']} logs ({cleared} file"
524
+ f"{'' if cleared == 1 else 's'}, {round(total_size / 1024)} KB freed)"
525
+ )
526
+ cleaned_sessions.append({
527
+ "session_name": session["name"],
528
+ "cleared": True,
529
+ "files_cleared": cleared,
530
+ "freed_kb": round(total_size / 1024),
531
+ "message": message,
532
+ })
533
+ if json_flag:
534
+ continue
535
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
536
+ else:
537
+ message = f"{session['name']}: no log found"
538
+ cleaned_sessions.append({
539
+ "session_name": session["name"],
540
+ "cleared": False,
541
+ "files_cleared": 0,
542
+ "freed_kb": 0,
543
+ "message": message,
544
+ })
545
+ if json_flag:
546
+ continue
547
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
548
+ if json_flag:
549
+ _write_json(ctx, _json_success("clean", "Cleaned session logs", cancelled=False, sessions=cleaned_sessions))
550
+ return 0
551
+
552
+ def handle_doctor(rest, ctx):
553
+ parsed = _parse_doctor_args(rest)
554
+ report = collect_health_report(
555
+ ctx["service"],
556
+ ctx["service"]["base_dir"],
557
+ env=ctx.get("env"),
558
+ spawn_sync=ctx.get("spawn_sync"),
559
+ check_provider_flags=parsed["check_provider_flags"],
560
+ )
561
+ report = filter_health_report(report, parsed["severity"])
562
+ if parsed["json"]:
563
+ _write_json(ctx, _json_success("doctor", "Collected health report", report=report))
564
+ else:
565
+ ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
566
+ return 0
567
+
568
+ def handle_repair(rest, ctx):
569
+ parsed = _parse_flag_args(rest, {
570
+ "--json": {"key": "json", "type": "bool", "default": False},
571
+ "--dry-run": {"key": "dry_run", "type": "bool", "default": False},
572
+ "--force": {"key": "force", "type": "bool", "default": False},
573
+ }, REPAIR_USAGE)
574
+ dry_run = parsed["dry_run"] or not parsed["force"]
575
+ report = repair_health(
576
+ ctx["service"],
577
+ ctx["service"]["base_dir"],
578
+ env=ctx.get("env"),
579
+ dry_run=dry_run,
580
+ force=parsed["force"],
581
+ )
582
+ if parsed["json"]:
583
+ _write_json(ctx, _json_success("repair", "Collected repair report", report=report))
584
+ else:
585
+ ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
586
+ if dry_run:
587
+ ctx["out"](f"{_dim('Tip: run cdx repair --force to apply safe repairs.', ctx['use_color'])}\n")
588
+ return 0
589
+
590
+ def handle_update(rest, ctx):
591
+ parsed = _parse_update_args(rest)
592
+ if parsed["all"]:
593
+ plan = collect_update_all_plan(ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"))
594
+ if not parsed["yes"]:
595
+ if parsed["json"]:
596
+ _write_json(ctx, _json_success("update.all", "Collected workstation update plan", plan=plan, apply_required=bool(plan["steps"])))
597
+ return 0
598
+ ctx["out"](f"{_format_update_all(plan, ctx['use_color'])}\n")
599
+ if not plan["steps"]:
600
+ return 0
601
+ if not ctx["stdin_is_tty"]:
602
+ raise CdxError("cdx update all requires an interactive terminal or --yes.")
603
+ if input("Apply this plan? [y/N] ").strip().lower() not in ("y", "yes"):
604
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
605
+ return 0
606
+ def progress(event):
607
+ if parsed["json"]:
608
+ return
609
+ step_name = event["step"]["name"]
610
+ if event["phase"] == "start":
611
+ progress_label = _info(f"[{event['index']}/{event['total']}]", ctx["use_color"])
612
+ ctx["out"](f"{progress_label} {step_name}...\n")
613
+ else:
614
+ ctx["out"](f" {_format_update_all_result(event['result'], ctx['use_color'])}\n")
615
+
616
+ results = apply_update_all_plan(plan, ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"), progress=progress)
617
+ failed = [row for row in results if row.get("returncode") != 0]
618
+ message = "Applied workstation update plan" if not failed else "Workstation update plan completed with failures"
619
+ if parsed["json"]:
620
+ _write_json(ctx, _json_success("update.all", message, plan=plan, results=results, failed=len(failed)))
621
+ else:
622
+ ctx["out"](f"{_success(message, ctx['use_color']) if not failed else _warn(message, ctx['use_color'])}\n")
623
+ return 1 if failed else 0
624
+ json_flag = parsed["json"]
625
+ current_version = str(ctx.get("version") or "").strip()
626
+ release_fetcher = ctx["options"].get("fetchLatestRelease") or fetch_latest_release
627
+ target_version = None
628
+ release_url = None
629
+ update_available = False
630
+
631
+ if parsed["version"] is not None:
632
+ target_version = str(parsed["version"]).strip().lstrip("v")
633
+ else:
634
+ try:
635
+ latest = (
636
+ release_fetcher()
637
+ if ctx["options"].get("fetchLatestRelease")
638
+ else fetch_latest_release_or_raise(env=ctx.get("env"))
639
+ )
640
+ except LatestReleaseCheckError as error:
641
+ raise CdxError(str(error)) from error
642
+ if not latest:
643
+ raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
644
+ target_version = str(latest.get("latest_version") or "").strip()
645
+ release_url = latest.get("url")
646
+ if not target_version:
647
+ raise CdxError("Unable to determine the latest cdx-manager release.")
648
+ update_available = is_newer_version(current_version, target_version)
649
+ if parsed["check"] or not update_available:
650
+ message = (
651
+ f"Update available: cdx-manager {target_version} (current {current_version})"
652
+ if update_available
653
+ else f"cdx-manager {current_version} is already up to date."
654
+ )
655
+ if json_flag:
656
+ _write_json(ctx, _json_success(
657
+ "update",
658
+ message,
659
+ checked=True,
660
+ update_available=update_available,
661
+ current_version=current_version,
662
+ target_version=target_version,
663
+ release_url=release_url,
664
+ warnings=[{
665
+ "code": "update_available",
666
+ "message": message,
667
+ "latest_version": target_version,
668
+ "url": release_url,
669
+ }] if update_available else [],
670
+ ))
671
+ return 0
672
+ ctx["out"](f"{_warn(message, ctx['use_color']) if update_available else _success(message, ctx['use_color'])}\n")
673
+ return 0
674
+
675
+ if not parsed["yes"]:
676
+ if not ctx["stdin_is_tty"]:
677
+ raise CdxError("Update requires an interactive terminal or --yes in non-interactive mode.")
678
+ answer = input(f"Update cdx-manager to {target_version}? [y/N] ")
679
+ if answer.strip().lower() not in ("y", "yes"):
680
+ message = "Cancelled."
681
+ if json_flag:
682
+ _write_json(ctx, _json_success("update", message, cancelled=True, current_version=current_version, target_version=target_version))
683
+ return 0
684
+ ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
685
+ return 0
686
+
687
+ plan = build_update_plan(
688
+ target_version=target_version,
689
+ package_root=ctx["options"].get("packageRoot"),
690
+ prefix=ctx["options"].get("prefix"),
691
+ base_prefix=ctx["options"].get("basePrefix"),
692
+ )
693
+ results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
694
+ failed = any((result.get("returncode") != 0) for result in results)
695
+ if failed:
696
+ raise CdxError(format_update_failure(results))
697
+
698
+ warnings = []
699
+ version_warning = verify_updated_command(
700
+ target_version,
701
+ runner=ctx["options"].get("runVersionCheck"),
702
+ env=ctx.get("env"),
703
+ )
704
+ if version_warning:
705
+ warnings.append(version_warning)
706
+
707
+ message = f"Updated cdx-manager to {target_version}"
708
+ if json_flag:
709
+ _write_json(ctx, _json_success(
710
+ "update",
711
+ message,
712
+ warnings=warnings,
713
+ updated=True,
714
+ current_version=current_version,
715
+ target_version=target_version,
716
+ mode=plan["mode"],
717
+ steps=results,
718
+ ))
719
+ return 0
720
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
721
+ for warning in warnings:
722
+ ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
723
+ return 0