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.
- package/README.md +214 -19
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/changelogs/CHANGELOGS_0_15_0.md +139 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +324 -39
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +190 -2
- package/src/cli_render.py +17 -0
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +849 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +713 -0
- package/src/config.py +70 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +142 -15
- package/src/run_command.py +112 -4
- package/src/run_failover.py +221 -0
- package/src/run_registry.py +127 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +698 -1426
- package/src/session_status.py +550 -0
- package/src/status_source.py +45 -0
- package/src/status_view.py +15 -34
|
@@ -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
|