-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
612 lines (520 loc) · 25.4 KB
/
main.py
File metadata and controls
612 lines (520 loc) · 25.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# stdlib imports
import os
from pathlib import Path
import shutil
import subprocess
import sys
import platform
import tempfile
from typing import Optional
# third-party imports
import requests
import typer
from rich.progress import Progress
from rich.console import Console
from rich.panel import Panel # auto
from rich.markdown import Markdown # auto
# local imports
from packages import Package
from network import download_binary, fetch_registry, get_file_size, verify_checksum
from storage import get_bin_dir, get_config_path, init_dir_structure, load_config, load_packages, save_packages
__version__ = "0.3.0"
def version_callback(value: bool):
if value:
typer.echo(typer.style(f"Centi Package Manager (centipm) v{__version__}", fg=typer.colors.BRIGHT_CYAN, bold=True))
raise typer.Exit()
app = typer.Typer(
context_settings={
"help_option_names": ['-h', '--help']
},
add_completion=False,
no_args_is_help=True
)
def mutually_exclusive_callback(group_size: int = 1):
group = set()
def callback(ctx: typer.Context, param: typer.CallbackParam, value: bool):
if value:
if len(group) >= group_size:
raise typer.BadParameter(f"{param.name} is mutually exclusive with {next(iter(group))}")
group.add(param.name)
return value
return callback
excl_cb = mutually_exclusive_callback()
def log_load(message: str, task, dim: bool = False):
"""Runs a task with a LOAD spinner"""
console = Console()
with console.status(
typer.style(f"LOAD ", fg=typer.colors.BRIGHT_BLUE, bold=not dim) +
typer.style(message, dim=dim)
):
result = task()
return result
def log(message: str, level: str = "INFO", bold: bool = False, dim: bool = False) -> None:
color_map = {
"INFO": typer.colors.CYAN,
"LOAD": typer.colors.BRIGHT_BLUE,
"FAIL": typer.colors.BRIGHT_RED,
"DONE": typer.colors.BRIGHT_GREEN,
"WARN": typer.colors.BRIGHT_YELLOW,
"GUIDE": typer.colors.YELLOW,
"NOTE": typer.colors.BRIGHT_CYAN
}
color = color_map.get(level, typer.colors.WHITE)
styled_level = typer.style(f"{level}", fg=color, bold=bold, dim=dim)
styled_message = (
typer.style(message, bold=bold, dim=dim)
if level != "FAIL" else
typer.style(message, fg=typer.colors.BRIGHT_YELLOW, dim=dim)
)
typer.echo(f"{styled_level} {styled_message}")
def dimmify(text: str) -> str:
return typer.style(text, dim=True)
@app.callback()
def startup(
version: bool = typer.Option(
None,
"--version", "-v",
callback=version_callback,
is_eager=True,
help="Shows the CentiPM version and exit"
),
):
init_dir_structure()
@app.command()
def registry():
"""Shows the registry URL"""
config = load_config()
registry_url = config["registry"]["url"]
typer.echo(typer.style(f"Current registry URL: ", fg=typer.colors.BRIGHT_CYAN, bold=True), nl=False)
typer.echo(registry_url)
@app.command()
def config():
"""Shows the config file path"""
typer.echo(typer.style(f"Config file path: ", fg=typer.colors.BRIGHT_CYAN, bold=True), nl=False)
typer.echo(get_config_path())
@app.command()
def clean():
"""Removes orphaned binaries from the bin directory"""
orphaned = [f for f in get_bin_dir().iterdir()
if f.name not in load_packages()]
if not orphaned:
log("No orphaned binaries found!", level="NOTE", bold=True)
return
log(f"Found {len(orphaned)} orphaned binary/binaries:", level="WARN", bold=True)
for f in orphaned:
typer.echo(f" {f.name}")
if not typer.confirm("Delete all orphaned binaries?", default=True):
log("Process aborted.", level="FAIL", bold=True)
return
for f in orphaned:
f.unlink()
log(f"Cleaned {len(orphaned)} orphaned binary/binaries!", level="DONE", bold=True)
@app.command()
def install(package: str,
version: str = "latest",
dim: bool = typer.Option(False, "--dim/--no-dim", help="Dim the output instead of showing it in bright colors"),
force: bool = typer.Option(False, "--force", "-f", help="Force installation")):
"""Installs a package"""
log(f"Finding '{package}' version {version}...", level="LOAD", bold=not dim, dim=dim)
registry_url = load_config()["registry"]["url"]
try:
registries = fetch_registry(registry_url)
except ConnectionError as e:
log(str(e), level="FAIL", bold=True)
log("Please check your internet connection and try again.", level="GUIDE")
return
if not force:
if package in load_packages():
log("Package already installed!", level="FAIL", bold=True) # TODO: Implement reinstall command
return
if package not in registries:
log("Package doesn't exist in the registry!", level="FAIL", bold=not dim, dim=dim)
log("If this package exists in another registry, please modify the registry URL inside ~/.centipm/config.toml", level="GUIDE", dim=dim)
return
# TODO: Versions
log(f"Found '{package}'! Installing...", level="LOAD", bold=not dim, dim=dim)
file_size = get_file_size(registries[package].url)
if file_size > 0:
with Progress(transient=True) as progress:
task = progress.add_task("Downloading...", total=file_size)
download_binary(
package,
registries[package].url,
on_progress=lambda size: progress.update(task, advance=size)
)
else:
console = Console()
with console.status("Downloading..."):
download_binary(package, registries[package].url)
log(f"Downloaded '{package}'!", level="INFO", bold=not dim, dim=dim)
if registries[package].sha256:
log("Verifying checksum...", level="LOAD", bold=not dim, dim=dim)
if verify_checksum(get_bin_dir() / package, registries[package].sha256):
log("Checksum verified!", level="DONE", bold=not dim, dim=dim)
else:
log("Checksum verification failed!", level="FAIL", bold=True)
log("The downloaded file may be corrupted or tampered with. Please try installing again.", level="GUIDE")
if not typer.confirm("Do you want to delete the downloaded file? (Recommended for your safety)", default=True):
log("File not deleted. Please manually delete the file at the path below to avoid potential security risks.", level="WARN", bold=True)
log(str(get_bin_dir() / package), level="WARN", bold=True)
new_packages = load_packages()
new_packages[registries[package].name] = registries[package].to_package()
save_packages(new_packages)
return
log("Aborting installation and removing downloaded file...", level="LOAD", bold=not dim, dim=dim)
(get_bin_dir() / package).unlink()
log("Installation aborted.", level="FAIL", bold=True)
return
else:
log("No checksum provided for this package, skipping verification.", level="WARN", bold=not dim, dim=dim)
log("This may be a security risk, as the file could be corrupted or tampered with. Please be cautious when installing packages without checksums.", level="WARN", dim=dim)
log("Saving entry..", level="LOAD", bold=not dim, dim=dim)
new_packages = load_packages()
new_packages[registries[package].name] = registries[package].to_package()
save_packages(new_packages)
log(f"Successfully installed '{package}'! Run it with 'centipm run {package}'", level="DONE", bold=not dim, dim=dim)
@app.command()
def remove(package: str, dim: bool = typer.Option(False, "--dim/--no-dim", help="Dim the output instead of showing it in bright colors")):
"""Removes an installed package"""
log(f"Finding {package}...", level="LOAD", bold=not dim, dim=dim)
if package not in load_packages():
log(f"Package '{package}' is not installed!", level="FAIL", bold=not dim, dim=dim)
return
log(f"Found '{package}'!", level="INFO", bold=not dim, dim=dim)
log(f"Removing {package}...", level="LOAD", bold=not dim, dim=dim)
(get_bin_dir() / package).unlink()
log(f"Removing entry...", level="LOAD", bold=not dim, dim=dim)
log(f"Saving entry..", level="LOAD", bold=not dim, dim=dim)
new_packages = load_packages()
del new_packages[package]
save_packages(new_packages)
log(f"Successfully removed '{package}'!", level="DONE", bold=not dim, dim=dim)
@app.command()
def view(detailed: bool = typer.Option(False, "--detailed", "-d", help="Show detailed information about each package, including description")):
"""Lists the installed packages"""
packages = load_packages()
if not packages:
log("No installed packages yet, get some using the 'install' command!", level="WARN", bold=True)
return
for package, info in packages.items():
typer.echo(typer.style(f"{info.author}/", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(typer.style(f"{package} ", bold=True), nl=False)
typer.echo(typer.style(info.version, fg=typer.colors.BRIGHT_GREEN, bold=True))
if detailed and info.tags:
typer.echo(typer.style(f" ({', '.join(info.tags)})", italic=True, dim=True))
typer.echo(f" {info.description}")
@app.command()
def info(package: str):
"""Show detailed information about a package in the registry"""
offline = False
registry_url = load_config()["registry"]["url"]
try:
registries = fetch_registry(registry_url)
except ConnectionError:
log("Registry unreachable, using offline mode.", level="WARN")
offline = True
registries = load_packages()
for name, info in registries.items():
if package.lower() == name.lower():
typer.echo(typer.style(f"{info.author}/", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(typer.style(f"{name} ", bold=True), nl=False)
typer.echo(typer.style(info.version, fg=typer.colors.BRIGHT_GREEN, bold=True), nl=False)
typer.echo(typer.style(" (installed)", dim=True) if name in load_packages() else "")
if info.tags:
typer.echo(typer.style(f" ({', '.join(info.tags)})", italic=True, dim=True))
typer.echo(f" {info.description}")
typer.echo()
typer.echo(typer.style(" Author: ", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(f" {info.author}")
typer.echo(typer.style(" Tags: ", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(f" {', '.join(info.tags) if info.tags else 'No tags'}")
if not offline:
typer.echo(typer.style(" SHA256: ", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(f" Provided" if info.sha256 else "Not Provided")
typer.echo(typer.style(" URL: ", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(f" {info.url}")
typer.echo(typer.style(" Runner: ", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(f" {info.runner if info.runner else "Direct"}")
return
log(f"Package '{package}' not found in the registry!", level="FAIL", bold=True)
# AUTO-GENERATED CHANGELOG COMMAND
@app.command()
def changelog():
"""Shows the changelog of the latest version"""
log("Fetching changelog...", level="LOAD", bold=True)
changelog_url = "https://raw.githubusercontent.com/tyydev1/centipm/refs/heads/main/CHANGELOG.md"
response = requests.get(changelog_url)
if response.status_code != 200:
log("Failed to fetch changelog!", level="FAIL", bold=True)
return
console = Console()
changelog_text = response.text
# Split by version headers (## [version])
lines = changelog_text.splitlines()
# Find the first version header
version_line_idx = None
for i, line in enumerate(lines):
if line.startswith("## ["):
version_line_idx = i
break
if version_line_idx is None:
log("No changelog versions found!", level="FAIL", bold=True)
return
# Extract version and date from the header
version_header = lines[version_line_idx]
try:
version = version_header.split("]")[0].lstrip("##[").strip()
date_part = version_header.split("]")[1].strip().lstrip("-").strip()
except (IndexError, AttributeError):
log("Could not parse changelog header!", level="FAIL", bold=True)
return
# Find the next version header or end of file
next_version_idx = None
for i in range(version_line_idx + 1, len(lines)):
if lines[i].startswith("## ["):
next_version_idx = i
break
# Extract content between this version and the next
if next_version_idx:
content_lines = lines[version_line_idx + 1:next_version_idx]
else:
content_lines = lines[version_line_idx + 1:]
# Clean up the content
formatted_content = "\n".join(line for line in content_lines if line.strip())
# Create a beautiful panel with the changelog
title = f"[bold cyan]{version}[/bold cyan] {date_part}"
changelog_panel = Panel(
Markdown(formatted_content) if formatted_content else "[dim]No changes recorded[/dim]",
title=title,
border_style="cyan",
padding=(1, 2)
)
console.print(changelog_panel)
@app.command()
def search(query: str,
author: bool = typer.Option(False, "--author", help="Search by author instead of package name and description", callback=excl_cb),
tag: bool = typer.Option(False, "--tags", help="Search by tags instead of package name and description", callback=excl_cb)):
"""Searches for a package in the registry"""
type = "author" if author else "tags" if tag else "name/description"
log(f"Searching for packages with '{query}' using {type} in the registry...", level="LOAD", bold=True)
registry_url = load_config()["registry"]["url"]
try:
registries = fetch_registry(registry_url)
except ConnectionError as e:
log(str(e), level="FAIL", bold=True)
log("Please check your internet connection and try again.", level="GUIDE")
return
results = []
for name, info in registries.items():
if not author:
if query.lower() in name.lower() or query.lower() in info.description.lower():
results.append((name, info))
elif tag and info.tags:
if any(query.lower() in tag.lower() for tag in info.tags):
results.append((name, info))
else:
if query.lower() in info.author.lower():
results.append((name, info))
if not results:
log(f"No results found for '{query}'!", level="WARN", bold=True)
return
packages = load_packages()
log(f"Found {len(results)} result(s) for {type} '{query}':", level="INFO", bold=True)
for name, info in results:
typer.echo(typer.style(f"{info.author}/", fg=typer.colors.BRIGHT_BLUE, bold=True), nl=False)
typer.echo(typer.style(f"{name} ", bold=True), nl=False)
typer.echo(typer.style(info.version, fg=typer.colors.BRIGHT_GREEN, bold=True), nl=False)
typer.echo(typer.style(" (installed)", dim=True) if name in packages else "")
if info.tags:
typer.echo(typer.style(f" ({', '.join(info.tags)})", italic=True, dim=True))
typer.echo(f" {info.description}")
@app.command(
context_settings={
"allow_extra_args": True,
"ignore_unknown_options": True,
"help_option_names": ['-h']
}
)
def run(package: str, extra: Optional[list[str]] = typer.Argument(None)):
"""Execute an installed package"""
if package not in (packages := load_packages()):
log(f"Package '{package}' is not installed!", level="FAIL", bold=True)
return
if not Path.exists(get_bin_dir() / package):
log(f"Binary for '{package}' is missing!", level="FAIL", bold=True)
log("This shouldn't happen, unless the files was manually removed.", level="GUIDE")
log("If this was unintentional, please submit an issue on the GitHub repository of this project!", level="GUIDE")
log("This is not the fault of the package, do not submit an issue to the package binary unless completely sure.", level="GUIDE")
return
try:
if runner := packages[package].runner:
subprocess.run([runner, str(get_bin_dir() / package)] + (extra or []))
else:
subprocess.run([str(get_bin_dir() / package)] + (extra or []))
except FileNotFoundError:
if runner:
log(f"Runner '{runner}' not found on your system!", level="FAIL", bold=True)
log(f"This package requires '{runner}' to run. Please install it and try again.", level="GUIDE")
else:
log(f"Binary for '{package}' could not be executed!", level="FAIL", bold=True)
log("The file may be corrupted. Try reinstalling the package.", level="GUIDE")
@app.command()
def reinstall(package: str, version: str = "latest", dim: bool = typer.Option(False, "--dim/--no-dim", help="Dim the output instead of showing it in bright colors")):
"""Reinstalls a package"""
remove(package, dim=dim)
install(package, version, dim=dim)
@app.command()
def update(package: Optional[str] = None):
"""Updates a package or all packages if no package is specified"""
try:
registries = fetch_registry(load_config()["registry"]['url'])
except ConnectionError as e:
log(str(e), level="FAIL", bold=True)
log("Please check your internet connection and try again.", level="GUIDE")
return
packages = load_packages()
if not packages:
log("No installed packages yet, get some using the 'install' command!", level="WARN", bold=True)
return
if package:
if package not in packages:
log(f"Package '{package}' is not installed!", level="FAIL", bold=True)
return
if package not in registries:
log(f"Package '{package}' doesn't exist in the registry!", level="FAIL", bold=True)
return
if registries[package].version == load_packages()[package].version:
log(f"Package '{package}' is up-to-date!", level="WARN", bold=True)
if typer.confirm("Reinstall anyway?"):
reinstall(package, dim=True)
log(f"Successfully reinstalled '{package}'!", level="DONE", bold=True)
return
reinstall(package, dim=True)
log(f"Successfully updated '{package}'!", level="DONE", bold=True)
if not package:
log("Executing full upgrade", level="NOTE", bold=True)
typer.echo("This will try to update all installed packages.")
if not typer.confirm("Continue?", default=True):
log("Process aborted.", level="FAIL", bold=True)
return
# Iiiit's design question time! Should we prompt the user for literally every package that's up-to-date?
# : Probably not. We should prompt the user for all reinstallation or ignore up-to-date packages
# Huh, great instinct, UX designer me!
# : Thanks. You should probably get to coding.
# Oh shoot!
# : The default should be Y!
# Who knew I had a good UX brain.
dont_reinstall = typer.confirm("Skip reinstallation of up-to-date packages?", default=True)
# Hey thinking me, how do we show "No updates installed" if all packages are up-to-date?
# : We can use a flag to check if any package was updated, and if not, show the message at the end.
# Great idea, let's do that!
any_updated: bool = False
for package in packages:
if registries[package].version == packages[package].version:
if not dont_reinstall:
log(f"Package '{package}' is up-to-date! Reinstalling anyway..", level="WARN", bold=True)
reinstall(package, dim=True)
log(f"Reinstalled '{package}'!", level="DONE", bold=True)
any_updated = True
continue
# If it DOESN'T match (previous check uses continue)
reinstall(package, dim=True)
log(f"Successfully updated '{package}'!", level="DONE", bold=True)
any_updated = True
if not any_updated:
log("No updates installed.", level="NOTE", bold=True)
@app.command(name="update-self")
def update_self(prerelease: bool = typer.Option(False, "--pre-release", help="Include prerelease versions in the update check")):
"""Updates CentiPM itself to the latest stable or pre-release version on GitHub releases"""
if not os.access(sys.executable, os.W_OK):
log("Cannot update CentiPM: no write permission to the binary!", level="FAIL", bold=True)
log(f"Try running with sudo (or as administrator): sudo centipm update-self", level="GUIDE")
log(f"If you installed CentiPM via pip/source code, this command would not work. "
"Please install the latest version manually.", level="GUIDE")
return
platform_map = {
"Linux": "linux",
"Darwin": "macos",
"Windows": "windows"
}
system = platform.system()
if system not in platform_map:
log(f"Unsupported platform: {system}", level="FAIL", bold=True)
log("Wha- how did you get this error? I thought I covered all platforms!", level="GUIDE")
log("Please submit an issue on the GitHub repository of this project, including the output of 'platform.system()' and 'platform.version()'!", level="GUIDE")
log("Seriously though, CentiPM should work on any platform with Python 3.14. Maybe install the Linux (manually, I'm sorry) version in the meantime?", level="GUIDE")
return
target_name = platform_map[system]
asset_url = None
if not typer.confirm("This will update CentiPM itself. Continue?", default=True):
log("Process aborted.", level="FAIL", bold=True)
return
target_release_type = "pre-release" if prerelease else "release"
if prerelease:
typer.echo("Pre-release versions may be unstable. This flag will check for the absolutely newest release, including pre-releases.")
if not typer.confirm("Continue? ", default=True): # set to true because the user already used the flag
log("Skipping pre-release scans..", level="INFO", bold=True)
target_release_type = "release"
try:
match target_release_type:
case "pre-release":
response = requests.get("https://api.github.com/repos/tyydev1/centipm/releases")
response.raise_for_status()
releases = response.json()
if not releases:
log("No releases found!", level="FAIL", bold=True)
return
json = releases[0]
case "release":
response = requests.get("https://api.github.com/repos/tyydev1/centipm/releases/latest")
response.raise_for_status()
json = response.json()
except requests.exceptions.RequestException as e:
log(f"Failed to fetch releases from GitHub: {e}", level="FAIL", bold=True)
log("Please check your internet connection and try again.", level="GUIDE")
return
except (ValueError, KeyError) as e:
log(f"Failed to parse release data: {e}", level="FAIL", bold=True)
log("The GitHub API response was unexpected. Please try again later.", level="GUIDE")
return
latest_version = json["tag_name"]
if latest_version.lstrip("v") == __version__:
log(f"You are already using the latest version of CentiPM ({__version__})!", level="NOTE", bold=True)
return
log(f"A new version of CentiPM is available: {latest_version}.", level="NOTE", bold=True)
if not typer.confirm("Do you want to update?", default=True):
log("Update cancelled.", level="FAIL", bold=True)
return
for asset in json["assets"]:
if asset["name"].endswith(target_name):
asset_url = asset["browser_download_url"]
break
if not asset_url:
log(f"Could not find asset for platform '{system}'!", level="FAIL", bold=True)
log("For the meantime, you can manually download the binary for the closest-like platform in the GitHub releases page, like Linux.", level="GUIDE")
return
temp_path = Path(sys.executable).parent / "centipm_update.tmp"
log(f"Updating CentiPM from version {__version__} to {latest_version}...", level="LOAD", bold=True)
file_size = get_file_size(asset_url)
if file_size > 0:
with Progress(transient=True) as progress:
task = progress.add_task("Downloading...", total=file_size)
download_binary(
"centipm",
asset_url,
dest=temp_path,
on_progress=lambda size: progress.update(task, advance=size)
)
else:
console = Console()
with console.status("Downloading..."):
download_binary(
"centipm",
asset_url,
dest=temp_path
)
os.remove(sys.executable)
shutil.move(str(temp_path), sys.executable)
log("Successfully updated CentiPM! Run 'centipm --version' to confirm.", level="DONE", bold=True)
if __name__ == "__main__":
app()