|
| 1 | +"""Interactive maintenance tools. Never require root for read-only operations.""" |
| 2 | +import datetime |
| 3 | +import importlib.util |
| 4 | +import os |
| 5 | +from pathlib import Path |
| 6 | +import re |
| 7 | +import shutil |
| 8 | +import subprocess |
| 9 | +import sys |
| 10 | +import tempfile |
| 11 | + |
| 12 | +from configure import check_destination, deploy |
| 13 | + |
| 14 | + |
| 15 | +def run(args, *, check=True, capture=False, cwd=None, timeout=None): |
| 16 | + return subprocess.run(args, check=check, text=True, cwd=cwd, timeout=timeout, |
| 17 | + stdout=subprocess.PIPE if capture else None, |
| 18 | + stderr=subprocess.STDOUT if capture else None) |
| 19 | + |
| 20 | + |
| 21 | +def confirm(message, token='APPLY'): |
| 22 | + print(message) |
| 23 | + if input(f'Type {token} to continue: ').strip() != token: |
| 24 | + raise ValueError('Cancelled; no further changes made.') |
| 25 | + |
| 26 | + |
| 27 | +def state_dir(): |
| 28 | + return Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'commander-toolbox' |
| 29 | + |
| 30 | + |
| 31 | +def safe_text(text): |
| 32 | + # Prevent log output from injecting terminal control sequences when viewed. |
| 33 | + text = re.sub(r'\x1b\][^\x07]*(?:\x07|\x1b\\)', '', text) |
| 34 | + text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text) |
| 35 | + return ''.join(c for c in text if c in '\n\t' or (ord(c) >= 32 and not 127 <= ord(c) <= 159)) |
| 36 | + |
| 37 | + |
| 38 | +def diagnostics(): |
| 39 | + reports = ['Commander Toolbox diagnostics (read-only)', Path('/etc/os-release').read_text()] |
| 40 | + commands = [ |
| 41 | + ['uname', '-srmo'], ['systemd-detect-virt'], ['df', '-h', '/', str(Path.home())], |
| 42 | + ['lspci', '-nnk'], ['nvidia-smi', '--query-gpu=name,driver_version,memory.total', '--format=csv'], |
| 43 | + ['clinfo', '-l'], ['vulkaninfo', '--summary'], ['mokutil', '--sb-state'], |
| 44 | + ] |
| 45 | + for args in commands: |
| 46 | + reports.append('\n$ ' + ' '.join(args)) |
| 47 | + if not shutil.which(args[0]): |
| 48 | + reports.append('Not installed; skipped.') |
| 49 | + continue |
| 50 | + try: |
| 51 | + result = run(args, check=False, capture=True, timeout=15) |
| 52 | + reports.append(safe_text(result.stdout)) |
| 53 | + reports.append(f'Exit status: {result.returncode}') |
| 54 | + except subprocess.TimeoutExpired: |
| 55 | + reports.append('Timed out after 15 seconds.') |
| 56 | + reports.append('\nGraphics device access:') |
| 57 | + for device in sorted(Path('/dev/dri').glob('renderD*')) + [Path('/dev/kfd')]: |
| 58 | + if device.exists(): |
| 59 | + reports.append(f'{device}: read={os.access(device, os.R_OK)} write={os.access(device, os.W_OK)}') |
| 60 | + reports.append('A Virtio/VMware/VirtualBox adapter does not establish Resolve GPU support.\n' |
| 61 | + 'Missing diagnostic tools or successful checks do not prove application compatibility.') |
| 62 | + report = '\n'.join(reports) |
| 63 | + print(report) |
| 64 | + if input('Save this report to your private toolbox state directory? [y/N]: ').lower() == 'y': |
| 65 | + directory = state_dir() / 'diagnostics' |
| 66 | + check_destination(directory) |
| 67 | + directory.mkdir(parents=True, exist_ok=True, mode=0o700) |
| 68 | + path = directory / (datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '.txt') |
| 69 | + with path.open('x') as handle: |
| 70 | + path.chmod(0o600) |
| 71 | + handle.write(report) |
| 72 | + print(f'Saved: {path}') |
| 73 | + |
| 74 | + |
| 75 | +BACKUP = re.compile(r'^(.+)\.commander-backup-[0-9][0-9-]*$') |
| 76 | + |
| 77 | + |
| 78 | +def discover_backups(home=None): |
| 79 | + home = Path(home or Path.home()) |
| 80 | + roots = [Path(os.environ.get('XDG_CONFIG_HOME', home / '.config')), |
| 81 | + home / '.local/bin', Path(os.environ.get('XDG_DATA_HOME', home / '.local/share'))] |
| 82 | + found = [p for p in home.iterdir() if BACKUP.fullmatch(p.name) and not p.is_symlink()] |
| 83 | + for root in roots: |
| 84 | + if not root.is_dir() or root.is_symlink(): |
| 85 | + continue |
| 86 | + for directory, dirs, files in os.walk(root, followlinks=False): |
| 87 | + base = Path(directory) |
| 88 | + for name in dirs + files: |
| 89 | + path = base / name |
| 90 | + if BACKUP.fullmatch(name) and not path.is_symlink(): |
| 91 | + found.append(path) |
| 92 | + dirs[:] = [d for d in dirs if not BACKUP.fullmatch(d) and d not in |
| 93 | + ('.git', 'node_modules', 'sources', 'Trash', 'containers', 'Steam') |
| 94 | + and not (base / d).is_symlink()] |
| 95 | + return sorted(set(found), key=lambda p: p.stat().st_mtime, reverse=True) |
| 96 | + |
| 97 | + |
| 98 | +def restore_backup(source): |
| 99 | + source = Path(source) |
| 100 | + match = BACKUP.fullmatch(source.name) |
| 101 | + if not match: |
| 102 | + raise ValueError('Not a Commander backup.') |
| 103 | + check_destination(source) |
| 104 | + destination = source.with_name(match[1]) |
| 105 | + check_destination(destination) |
| 106 | + # Never follow links inside a backup into another configuration owner. |
| 107 | + if source.is_dir(): |
| 108 | + for directory, dirs, files in os.walk(source, followlinks=False): |
| 109 | + if any((Path(directory) / name).is_symlink() for name in dirs + files): |
| 110 | + raise ValueError('Backup contains symlinks; restore it through its configuration owner.') |
| 111 | + deploy(source, destination) |
| 112 | + return destination |
| 113 | + |
| 114 | + |
| 115 | +def backups(): |
| 116 | + paths = discover_backups() |
| 117 | + if not paths: |
| 118 | + print('No Commander user-configuration backups found. System/boot backups are not restored here.') |
| 119 | + return |
| 120 | + for index, path in enumerate(paths, 1): |
| 121 | + print(f'{index}. {path}') |
| 122 | + selection = input('Backup number to restore (Enter cancels): ').strip() |
| 123 | + if not selection: |
| 124 | + return |
| 125 | + if not selection.isdigit() or not 1 <= int(selection) <= len(paths): |
| 126 | + raise ValueError('Invalid backup number.') |
| 127 | + source = paths[int(selection) - 1] |
| 128 | + target = source.with_name(BACKUP.fullmatch(source.name)[1]) |
| 129 | + confirm(f'Restore {source}\nto {target}\nThe current target will receive its own backup.') |
| 130 | + print(f'Restored: {restore_backup(source)}') |
| 131 | + |
| 132 | + |
| 133 | +def history(): |
| 134 | + directory = state_dir() / 'history' |
| 135 | + check_destination(directory) |
| 136 | + if not directory.exists(): |
| 137 | + print('No action history yet. Only actions run with the updated toolbox are recorded.') |
| 138 | + return |
| 139 | + runs = sorted((p for p in directory.iterdir() if p.is_dir() and not p.is_symlink()), reverse=True) |
| 140 | + for index, path in enumerate(runs, 1): |
| 141 | + try: |
| 142 | + timestamp = datetime.datetime.fromtimestamp(int(path.name.split('-')[0]) / 1_000_000_000).isoformat(' ', timespec='seconds') |
| 143 | + except (ValueError, OverflowError, OSError): |
| 144 | + timestamp = path.name |
| 145 | + print(f'\n{index}. {timestamp}') |
| 146 | + for name in sorted(path.glob('*.name')): |
| 147 | + status = name.with_suffix('.status') |
| 148 | + result = status.read_text().strip() if status.exists() else 'unknown' |
| 149 | + result = {'0': 'success', 'pending': 'not started', 'running': 'running or interrupted'}.get(result, f'exit {result}') |
| 150 | + print(f' {safe_text(name.read_text().strip())}: {result}') |
| 151 | + print(' Saved log available' if (path / 'output.log').is_file() else ' No output log saved') |
| 152 | + choice = input('\nRun number to read its saved log (Enter closes): ').strip() |
| 153 | + if not choice: |
| 154 | + return |
| 155 | + if not choice.isdigit() or not 1 <= int(choice) <= len(runs): |
| 156 | + raise ValueError('Invalid run number.') |
| 157 | + log = runs[int(choice) - 1] / 'output.log' |
| 158 | + check_destination(log) |
| 159 | + if not log.is_file(): |
| 160 | + print('No saved log. Use L after an action finishes to save its terminal output.') |
| 161 | + return |
| 162 | + print(safe_text(log.read_text(errors='replace'))) |
| 163 | + |
| 164 | + |
| 165 | +def git(repo, *args): |
| 166 | + return run(['git', '-C', str(repo), *args], capture=True).stdout.strip() |
| 167 | + |
| 168 | + |
| 169 | +def update_checkout(repo): |
| 170 | + repo = Path(repo).expanduser().resolve() |
| 171 | + if Path(git(repo, 'rev-parse', '--show-toplevel')) != repo: |
| 172 | + raise ValueError('Choose the repository root.') |
| 173 | + if git(repo, 'status', '--porcelain', '--untracked-files=all'): |
| 174 | + raise ValueError('Local changes found. Commit or stash them yourself before updating; nothing was discarded.') |
| 175 | + branch = git(repo, 'symbolic-ref', '--quiet', '--short', 'HEAD') |
| 176 | + remote = git(repo, 'remote', 'get-url', 'origin') |
| 177 | + allowed = ('https://github.com/Commanderx-code/commander-toolbox.git', |
| 178 | + 'https://github.com/Commanderx-code/commander-toolbox', |
| 179 | + 'git@github.com:Commanderx-code/commander-toolbox.git') |
| 180 | + if remote not in allowed: |
| 181 | + raise ValueError('origin is not Commanderx-code/commander-toolbox. Update custom forks manually.') |
| 182 | + print(f'Checkout: {repo}\nBranch: {branch}\nCurrent revision: {git(repo, "rev-parse", "--short", "HEAD")}\nRemote: {remote}') |
| 183 | + confirm('Fetch origin/main, fast-forward this checkout and build the release executable?') |
| 184 | + run(['git', '-C', str(repo), 'fetch', 'origin', 'main']) |
| 185 | + if run(['git', '-C', str(repo), 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'], check=False).returncode: |
| 186 | + raise ValueError('Local commits are ahead of or diverged from origin/main. Nothing was reset or merged.') |
| 187 | + run(['git', '-C', str(repo), 'merge', '--ff-only', 'origin/main']) |
| 188 | + try: |
| 189 | + run(['cargo', 'build', '--locked', '--release', '--package', 'linutil_tui', '--target-dir', str(repo / 'target')], cwd=repo) |
| 190 | + except subprocess.CalledProcessError: |
| 191 | + raise ValueError('Checkout updated, but the build failed. Your installed executable was not replaced. Fix the build and retry.') from None |
| 192 | + directory = state_dir() |
| 193 | + check_destination(directory / 'checkout') |
| 194 | + directory.mkdir(parents=True, exist_ok=True, mode=0o700) |
| 195 | + (directory / 'checkout').write_text(str(repo)) |
| 196 | + return repo / 'target/release/commander-toolbox' |
| 197 | + |
| 198 | + |
| 199 | +def updater(): |
| 200 | + saved = state_dir() / 'checkout' |
| 201 | + default = saved.read_text().strip() if saved.is_file() and not saved.is_symlink() else os.environ.get('COMMANDER_TOOLBOX_SOURCE', '') |
| 202 | + entered = input(f'Checkout path [{default}]: ').strip() or default |
| 203 | + if not entered: |
| 204 | + raise ValueError('A Git checkout is required; clone Commander Toolbox first.') |
| 205 | + binary = update_checkout(entered) |
| 206 | + print(f'Built: {binary}\nClose this running toolbox before launching the new version.') |
| 207 | + target = Path.home() / '.local/bin/commander-toolbox' |
| 208 | + if target.is_file(): |
| 209 | + with target.open('rb') as handle: |
| 210 | + is_script = handle.read(2) == b'#!' |
| 211 | + if is_script: |
| 212 | + print('Your script launcher is preserved. If it points to this checkout, the next run uses the update.') |
| 213 | + return |
| 214 | + if input(f'Install executable at {target}? Type INSTALL, or Enter to keep the build only: ') == 'INSTALL': |
| 215 | + check_destination(target) |
| 216 | + # Copy to a sibling staging file, then rename: never truncate a running binary. |
| 217 | + target.parent.mkdir(parents=True, exist_ok=True) |
| 218 | + with tempfile.TemporaryDirectory(dir=target.parent) as tmp: |
| 219 | + staging = Path(tmp) / 'commander-toolbox' |
| 220 | + shutil.copy2(binary, staging) |
| 221 | + deploy(staging, target) |
| 222 | + print('User executable installed. Restart the toolbox.') |
| 223 | + |
| 224 | + |
| 225 | +def resolve_maintenance(): |
| 226 | + print('1. Native dependency check\n2. Container status and dependency check\n3. Repair native launcher\n' |
| 227 | + '4. Repair container launcher\n5. Remove Resolve container\n6. Native removal instructions') |
| 228 | + choice = input('Choose [1-6], Enter cancels: ').strip() |
| 229 | + if not choice: |
| 230 | + return |
| 231 | + if choice == '6': |
| 232 | + print('Back up/export Resolve projects first. For a native vendor install, use its uninstaller at\n' |
| 233 | + '/opt/resolve/installer (run its help to inspect uninstall options). For an AUR-managed install,\n' |
| 234 | + 'use your package manager. This tool does not delete native Resolve files or project data.') |
| 235 | + return |
| 236 | + if choice not in ('1', '2', '3', '4', '5'): |
| 237 | + raise ValueError('Invalid choice.') |
| 238 | + container = choice in ('2', '4', '5') |
| 239 | + os.environ['DBX_CONTAINER_MANAGER'] = 'podman' |
| 240 | + if container: |
| 241 | + run(['podman', 'container', 'exists', 'commander-resolve']) |
| 242 | + run(['podman', 'ps', '-a', '--filter', 'name=^commander-resolve$', '--format', '{{.Names}} {{.Status}} {{.Image}}']) |
| 243 | + prefix = ['distrobox', 'enter', '--name', 'commander-resolve', '--'] |
| 244 | + else: |
| 245 | + if not Path('/opt/resolve/bin/resolve').is_file(): |
| 246 | + raise ValueError('Native Resolve was not found.') |
| 247 | + prefix = [] |
| 248 | + if choice == '5': |
| 249 | + confirm('Remove commander-resolve and its container-local data? Export/back up projects first.\n' |
| 250 | + 'Shared home files and host GPU drivers remain.', 'REMOVE commander-resolve') |
| 251 | + run(['distrobox', 'rm', '--force', 'commander-resolve']) |
| 252 | + print('Container removed. Its launcher can be restored/replaced through Config Backup Restore or launcher repair.') |
| 253 | + return |
| 254 | + spec = importlib.util.spec_from_file_location('resolve_launcher', Path(__file__).with_name('resolve-launcher.py')) |
| 255 | + module = importlib.util.module_from_spec(spec) |
| 256 | + spec.loader.exec_module(module) |
| 257 | + with tempfile.TemporaryDirectory(prefix='.resolve-check-', dir=Path.home()) as tmp: |
| 258 | + script = Path(tmp) / 'runtime.sh' |
| 259 | + script.write_text(module.NATIVE) |
| 260 | + run(prefix + ['sh', str(script), '--check']) |
| 261 | + if choice in ('3', '4'): |
| 262 | + confirm('Recreate the Commander Resolve launcher and desktop entry with backups?') |
| 263 | + module.install('container' if container else 'native') |
| 264 | + print('Dependency check passed. This does not prove GPU acceleration or codec support.') |
| 265 | + |
| 266 | + |
| 267 | +if __name__ == '__main__': |
| 268 | + actions = {'diagnostics': diagnostics, 'backups': backups, 'history': history, |
| 269 | + 'update': updater, 'resolve': resolve_maintenance} |
| 270 | + try: |
| 271 | + actions[sys.argv[1]]() |
| 272 | + except (ValueError, OSError, subprocess.CalledProcessError, EOFError, KeyboardInterrupt) as error: |
| 273 | + print(f'Stopped: {error}', file=sys.stderr) |
| 274 | + sys.exit(1) |
0 commit comments