@danhachuel/thunderbolt 0.3.96 → 0.3.98

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/app/main.py CHANGED
@@ -4,6 +4,7 @@ import hashlib
4
4
  import json
5
5
  import mimetypes
6
6
  import re
7
+ import time
7
8
  from contextlib import nullcontext
8
9
  from datetime import date, datetime, timezone
9
10
  import sys
@@ -42,6 +43,7 @@ from hermes_ui.material_sources import apply_material_source_cards_to_settings,
42
43
  from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
43
44
  from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_cards_for_pool, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
44
45
  from hermes_ui.music import create_music_task, list_music_files, list_music_tasks, materialize_suno_audio, request_suno_generation, run_music_task, store_music_file, store_voiceover_file, transition_music_task
46
+ from hermes_ui.music_generation import MUSIC_GENRES, MUSIC_VOCAL_OPTIONS, generate_music_fields
45
47
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
46
48
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
47
49
  from hermes_ui.influencers import BACKEND_OPTIONS, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, backend_name, backend_status, get_repository, test_backend
@@ -49,6 +51,7 @@ from hermes_ui.logs import list_logs, logs_to_rows
49
51
  from hermes_ui.languages import LANGUAGE_CODES, VIDEO_LANGUAGE_CODES, LANGUAGE_FLAG_DATA_URIS, language_code, language_label, ui_language_menu_label, ui_text, video_language_label, video_language_options
50
52
  from hermes_ui.api_key_tests import test_apify_credentials, test_influencer_database, test_innertube_api_key, test_kaggle_credentials, test_material_source_credentials, test_media_provider_card, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
51
53
  from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
54
+ from hermes_ui.update_manager import check_version, update_to_latest
52
55
 
53
56
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
54
57
  from hermes_ui.script_generation import generate_script_document
@@ -1269,6 +1272,48 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
1269
1272
 
1270
1273
  def render_dashboard():
1271
1274
  ui_language = current_ui_language()
1275
+ update_area, version_area = st.columns([1.45, 4.55])
1276
+ with update_area:
1277
+ st.markdown(
1278
+ """
1279
+ <style>
1280
+ div[data-testid="stButton"] button[kind="primary"] {
1281
+ background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
1282
+ color: #ffffff;
1283
+ border: 1px solid #8b5cf6;
1284
+ font-weight: 700;
1285
+ box-shadow: 0 8px 20px rgba(79, 70, 229, 0.28);
1286
+ }
1287
+ div[data-testid="stButton"] button[kind="primary"]:hover {
1288
+ border-color: #c4b5fd;
1289
+ filter: brightness(1.08);
1290
+ }
1291
+ </style>
1292
+ """,
1293
+ unsafe_allow_html=True,
1294
+ )
1295
+ if st.button("Atualizar Versão", key="home_update_version", use_container_width=True, type="primary", icon=":material/system_update:"):
1296
+ with st.spinner("A instalar a versão mais recente…"):
1297
+ st.session_state["home_update_result"] = update_to_latest(APP_VERSION)
1298
+ with version_area:
1299
+ cache_key = "home_update_version_check"
1300
+ checked_at_key = "home_update_version_checked_at"
1301
+ if not st.session_state.get(cache_key) or time.monotonic() - float(st.session_state.get(checked_at_key, 0)) > 300:
1302
+ st.session_state[cache_key] = check_version(APP_VERSION)
1303
+ st.session_state[checked_at_key] = time.monotonic()
1304
+ version_status = st.session_state[cache_key]
1305
+ if version_status.update_available:
1306
+ st.info(f"Nova versão disponível: {version_status.latest_version}. A versão actual é {APP_VERSION or 'desconhecida'}.")
1307
+ elif version_status.error:
1308
+ st.caption(f"Versão actual: {APP_VERSION or 'desconhecida'} · verificação de actualização indisponível.")
1309
+ else:
1310
+ st.caption(f"Versão actual: {APP_VERSION or 'desconhecida'} · já está actualizada ({version_status.latest_version}).")
1311
+ update_result = st.session_state.get("home_update_result")
1312
+ if update_result is not None:
1313
+ if update_result.ok:
1314
+ st.success(update_result.message)
1315
+ else:
1316
+ st.error(update_result.message)
1272
1317
  st.title("Thunderbolt")
1273
1318
  st.caption(ui_text("Interface local para operação e automação de conteúdo faceless", ui_language))
1274
1319
  summary = pipeline_summary()
@@ -2607,20 +2652,75 @@ def render_music_creation():
2607
2652
  st.title("Criação de Músicas")
2608
2653
  st.caption("Crie apenas áudio. Os pedidos desta página não criam vídeo, não usam o MoneyPrinterTurbo e não entram no Backlog Vídeos.")
2609
2654
  settings = read_json("settings.json", {})
2610
- with st.form("create_music_task_form"):
2611
- provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
2612
- title = st.text_input("Título da música", key="music_task_title")
2613
- prompt = st.text_area("Prompt musical", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="music_task_prompt", height=150)
2614
- lyria_model = ""
2615
- if provider_label == "Google Lyria":
2616
- models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
2617
- configured = str(settings.get("lyria_model") or models[0])
2618
- lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
2619
- submitted = st.form_submit_button("Adicionar ao Music Backlog", type="primary", use_container_width=True)
2620
- if submitted:
2655
+ generated_fields = st.session_state.pop("music_task_generated_fields", None)
2656
+ if isinstance(generated_fields, dict):
2657
+ for state_key, generated_key in (
2658
+ ("music_task_title", "title"),
2659
+ ("music_task_language", "language"),
2660
+ ("music_task_genre", "genre"),
2661
+ ("music_task_vocal", "vocal"),
2662
+ ("music_task_references", "references"),
2663
+ ("music_task_prompt", "prompt"),
2664
+ ):
2665
+ st.session_state[state_key] = str(generated_fields.get(generated_key) or "")
2666
+ provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
2667
+ theme = st.text_input("Tema / assunto principal", key="music_task_theme", placeholder="Ex.: uma viagem nocturna pela costa portuguesa")
2668
+ title = st.text_input("Título da música", key="music_task_title")
2669
+ language = st.selectbox(
2670
+ "Idioma da letra/música",
2671
+ list(LANGUAGE_CODES),
2672
+ index=list(LANGUAGE_CODES).index(str(st.session_state.get("music_task_language") or "pt")) if str(st.session_state.get("music_task_language") or "pt") in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt"),
2673
+ format_func=language_label,
2674
+ key="music_task_language",
2675
+ )
2676
+ genre = st.selectbox("Género musical", list(MUSIC_GENRES), key="music_task_genre")
2677
+ vocal = st.selectbox("Vocal", list(MUSIC_VOCAL_OPTIONS), key="music_task_vocal")
2678
+ references = st.text_area(
2679
+ "Referências culturais, paisagens, clima ou artistas similares (opcional)",
2680
+ key="music_task_references",
2681
+ placeholder="Ex.: pôr do sol mediterrânico, estrada molhada, nostalgia suave e arranjos acústicos contemporâneos",
2682
+ height=90,
2683
+ )
2684
+ if st.button("Gerar campos musicais com IA", key="music_task_generate_fields", use_container_width=True, icon=":material/auto_awesome:"):
2621
2685
  try:
2622
- task = create_music_task(provider_label, prompt, title, lyria_model)
2623
- st.success(f"Música adicionada ao Music Backlog: {task['title']}")
2686
+ with st.spinner("A criar título, letra e prompt musical originais…"):
2687
+ generated = generate_music_fields(
2688
+ settings,
2689
+ theme=theme,
2690
+ language=language,
2691
+ genre=genre,
2692
+ vocal=vocal,
2693
+ references=references,
2694
+ )
2695
+ st.session_state["music_task_generated_fields"] = generated
2696
+ st.rerun()
2697
+ except CreativeGenerationError as exc:
2698
+ st.error(str(exc))
2699
+ prompt = st.text_area(
2700
+ "Prompt musical",
2701
+ placeholder="Use Gerar campos musicais com IA para criar letra e estilo completos, ou escreva um prompt próprio.",
2702
+ key="music_task_prompt",
2703
+ height=300,
2704
+ )
2705
+ lyria_model = ""
2706
+ if provider_label == "Google Lyria":
2707
+ models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
2708
+ configured = str(settings.get("lyria_model") or models[0])
2709
+ lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
2710
+ if st.button("Gerar Música", key="music_task_submit", type="primary", use_container_width=True, icon=":material/music_note:"):
2711
+ try:
2712
+ task = create_music_task(
2713
+ provider_label,
2714
+ prompt,
2715
+ title,
2716
+ lyria_model,
2717
+ language=language,
2718
+ genre=genre,
2719
+ vocal=vocal,
2720
+ references=references,
2721
+ theme=theme,
2722
+ )
2723
+ st.success(f"Música criada no Music Backlog: {task['title']}")
2624
2724
  st.rerun()
2625
2725
  except ValueError as exc:
2626
2726
  st.error(str(exc))
@@ -97,7 +97,14 @@ def materialize_suno_audio(data: dict[str, Any], title: str = "suno-generated.mp
97
97
  return store_music_file(title, response.content)
98
98
 
99
99
 
100
- def request_suno_generation(settings: dict[str, Any], prompt: str, title: str = "", duration_seconds: int = 120) -> dict[str, Any]:
100
+ def request_suno_generation(
101
+ settings: dict[str, Any],
102
+ prompt: str,
103
+ title: str = "",
104
+ duration_seconds: int = 120,
105
+ *,
106
+ make_instrumental: bool = True,
107
+ ) -> dict[str, Any]:
101
108
  """Request music from a configured Suno-compatible endpoint.
102
109
 
103
110
  Suno-compatible deployments expose different endpoint paths; the UI therefore
@@ -109,7 +116,12 @@ def request_suno_generation(settings: dict[str, Any], prompt: str, title: str =
109
116
  if not api_key or not base_url:
110
117
  return {"ok": False, "message": "Configure Suno API Key e Suno API Base URL em Configurações antes de solicitar uma música.", "data": {}}
111
118
  url = endpoint if endpoint.startswith("http") else f"{base_url}/{endpoint.lstrip('/')}"
112
- payload = {"prompt": prompt.strip(), "title": title.strip(), "duration": max(120, int(duration_seconds)), "make_instrumental": True}
119
+ payload = {
120
+ "prompt": prompt.strip(),
121
+ "title": title.strip(),
122
+ "duration": max(120, int(duration_seconds)),
123
+ "make_instrumental": bool(make_instrumental),
124
+ }
113
125
  try:
114
126
  response = requests.post(url, json=payload, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=30)
115
127
  if response.status_code >= 400:
@@ -132,7 +144,18 @@ def _save_music_tasks(tasks: list[dict[str, Any]]) -> None:
132
144
  storage.write_json("music_tasks.json", tasks)
133
145
 
134
146
 
135
- def create_music_task(provider: str, prompt: str, title: str, model: str = "") -> dict[str, Any]:
147
+ def create_music_task(
148
+ provider: str,
149
+ prompt: str,
150
+ title: str,
151
+ model: str = "",
152
+ *,
153
+ language: str = "",
154
+ genre: str = "",
155
+ vocal: str = "",
156
+ references: str = "",
157
+ theme: str = "",
158
+ ) -> dict[str, Any]:
136
159
  """Enqueue one audio-only generation; no video task or video worker is used."""
137
160
  cleaned_prompt = str(prompt or "").strip()
138
161
  if not cleaned_prompt:
@@ -145,6 +168,12 @@ def create_music_task(provider: str, prompt: str, title: str, model: str = "") -
145
168
  "model": str(model or "").strip(),
146
169
  "title": str(title or "Música sem título").strip() or "Música sem título",
147
170
  "prompt": cleaned_prompt,
171
+ "language": str(language or "").strip(),
172
+ "genre": str(genre or "").strip(),
173
+ "vocal": str(vocal or "").strip(),
174
+ "references": str(references or "").strip(),
175
+ "theme": str(theme or "").strip(),
176
+ "duration_seconds": 120,
148
177
  "state": "to_do",
149
178
  "stage": "music_generation",
150
179
  "progress": 0,
@@ -240,7 +269,13 @@ def run_music_task(task_id: str, settings: dict[str, Any]) -> dict[str, Any] | N
240
269
  result = request_lyria_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""), str(task.get("model") or ""))
241
270
  audio_path = str((result.get("data") or {}).get("audio_path") or "")
242
271
  else:
243
- result = request_suno_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""))
272
+ result = request_suno_generation(
273
+ settings,
274
+ str(task.get("prompt") or ""),
275
+ str(task.get("title") or ""),
276
+ int(task.get("duration_seconds") or 120),
277
+ make_instrumental=not bool(str(task.get("vocal") or "").strip()),
278
+ )
244
279
  try:
245
280
  output = materialize_suno_audio(result.get("data") or {}, str(task.get("title") or "suno-generated.mp3")) if result.get("ok") else None
246
281
  audio_path = str(output or "")
@@ -0,0 +1,88 @@
1
+ """Structured, original music briefs generated through the configured LLM pool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .creative_generation import CreativeGenerationError, _chat_json
9
+ from .languages import language_code
10
+
11
+
12
+ MUSIC_GENRES = (
13
+ "Pop", "Rock", "Hip Hop", "EDM (Eletrônica)", "Techno", "Country", "Folk", "Indie", "K-pop", "Jazz",
14
+ "Reggae", "Classical", "Metal", "Punk", "Afrobeat (Nigéria)", "Ambient (Reino Unido)", "Anime (Japão)",
15
+ "Arabic Pop / Khaleeji (Mundo Árabe)", "Austropop (Áustria)", "Ballad (Internacional)", "Banda (México)",
16
+ "Blues (EUA)", "Bollywood / Indian (Índia)", "City Pop (Japão)", "Corridos Tumbados (México)", "C-pop (China)",
17
+ "Cumbia (Colômbia)", "Dangdut (Indonésia)", "Dansband (Suécia)", "Deutschrap (Alemanha)",
18
+ "Disco Polo (Polônia)", "Drum & Bass (Reino Unido)", "Entechno (Grécia)", "Fado (Portugal)",
19
+ "Flamenco (Espanha)", "Forró (Brasil)", "Funk (Brasil)", "Fusion (Internacional)", "Gamolan (Indonésia)",
20
+ "Gospel (EUA)", "Grime (Reino Unido)", "Hardstyle (Países Baixos)", "J-Pop (Japão)", "J-Rock (Japão)",
21
+ "K-Hip Hop (Coreia do Sul)", "Kizomba (Angola)", "Klapa (Croácia)", "Klezmer (Judeu / Europa Oriental)",
22
+ "Laïko (Grécia)", "Latin (América Latina)", "Mandopop (China / Taiwan)", "Māori (Nova Zelândia)", "MPB (Brasil)",
23
+ "Mundart (Dialect Pop) (Suíça)", "Pasifika (Pacífico)", "Pimba (Portugal)", "Pop Sueco (Suécia)", "R&B (EUA)",
24
+ "Rap Francês (França)", "Reggaeton (Porto Rico)", "Regional Mexicano (México)", "Samba / Pagode (Brasil)",
25
+ "Schlager (Alemanha)", "Sertanejo (Brasil)", "Trot (Coreia do Sul)", "Urbano / Trap (América Latina)",
26
+ "Variété (França)", "Vocaloid (Japão)",
27
+ )
28
+ MUSIC_VOCAL_OPTIONS = ("Masculino", "Feminina", "Coral")
29
+
30
+
31
+ def generate_music_fields(
32
+ settings: dict[str, Any],
33
+ *,
34
+ theme: str,
35
+ language: str,
36
+ genre: str,
37
+ vocal: str,
38
+ references: str = "",
39
+ ) -> dict[str, str]:
40
+ """Generate original, ready-to-use music fields through the active LLM pool."""
41
+ cleaned_theme = str(theme or "").strip()
42
+ if not cleaned_theme:
43
+ raise CreativeGenerationError("Escreva o tema ou assunto principal antes de gerar os campos musicais com IA.")
44
+ selected_genre = str(genre or "").strip()
45
+ selected_vocal = str(vocal or "").strip()
46
+ selected_language = language_code(language)
47
+ system = (
48
+ "És um compositor e produtor musical. Cria uma canção inteiramente original, sem reproduzir letras, melodias, "
49
+ "frases distintivas ou a identidade de artistas existentes. As referências fornecidas servem apenas para atributos "
50
+ "de alto nível, como instrumentação, clima, contexto cultural e energia. Responde apenas com JSON válido contendo "
51
+ "as chaves title, language, genre, vocal, cultural_references e music_prompt. "
52
+ "music_prompt deve ser Markdown legível, pronto para uma ferramenta de geração musical, com exactamente estas secções: "
53
+ "# 1. Nome da Música, # 2. Idioma da Música, # 3. Letra / Lyrics, # 4. Estilo / Style Prompt. "
54
+ "A letra deve ser original, incluir [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], "
55
+ "[Instrumental Solo], [Final Chorus] e [Outro], ter uma estrutura que vise pelo menos dois minutos, e o estilo deve "
56
+ "informar subgénero, vocal, instrumentação, BPM, clima, produção e estrutura."
57
+ )
58
+ user = json.dumps(
59
+ {
60
+ "theme": cleaned_theme,
61
+ "selected_language_code": selected_language,
62
+ "selected_genre": selected_genre,
63
+ "selected_vocal": selected_vocal,
64
+ "cultural_landscape_weather_or_artist_references": str(references or "").strip(),
65
+ "available_genres": list(MUSIC_GENRES),
66
+ "available_vocals": list(MUSIC_VOCAL_OPTIONS),
67
+ "requirements": {
68
+ "minimum_target_duration_seconds": 120,
69
+ "preserve_selected_language_genre_and_vocal_when_present": True,
70
+ "output_original_content_only": True,
71
+ },
72
+ },
73
+ ensure_ascii=False,
74
+ )
75
+ result = _chat_json(settings, system, user)
76
+ music_prompt = str(result.get("music_prompt") or "").strip()
77
+ if not music_prompt:
78
+ raise CreativeGenerationError("O provider LLM não devolveu um prompt musical válido.")
79
+ generated_genre = str(result.get("genre") or selected_genre).strip()
80
+ generated_vocal = str(result.get("vocal") or selected_vocal).strip()
81
+ return {
82
+ "title": str(result.get("title") or cleaned_theme).strip()[:180],
83
+ "language": language_code(result.get("language"), default=selected_language),
84
+ "genre": generated_genre if generated_genre in MUSIC_GENRES else selected_genre,
85
+ "vocal": generated_vocal if generated_vocal in MUSIC_VOCAL_OPTIONS else selected_vocal,
86
+ "references": str(result.get("cultural_references") or references or "").strip()[:800],
87
+ "prompt": music_prompt[:16000],
88
+ }
@@ -0,0 +1,108 @@
1
+ """Verificação e actualização local da versão distribuída pelo NPM.
2
+
3
+ O módulo não recebe nem manipula credenciais. A instalação só é iniciada após o
4
+ clique explícito do utilizador na interface local.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable
13
+
14
+ import requests
15
+
16
+
17
+ PACKAGE_NAME = "@danhachuel/thunderbolt"
18
+ REGISTRY_URL = "https://registry.npmjs.org/@danhachuel/thunderbolt/latest"
19
+ UPDATE_TIMEOUT_SECONDS = 20 * 60
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class VersionCheck:
24
+ """Version information displayed on the local home page."""
25
+
26
+ current_version: str
27
+ latest_version: str = ""
28
+ error: str = ""
29
+
30
+ @property
31
+ def update_available(self) -> bool:
32
+ return bool(self.latest_version and self.current_version and self.latest_version != self.current_version)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class UpdateResult:
37
+ """Sanitised result of an explicit local package update request."""
38
+
39
+ ok: bool
40
+ latest_version: str = ""
41
+ message: str = ""
42
+
43
+
44
+ def latest_package_version(*, timeout: int = 8, get: Callable[..., Any] = requests.get) -> str:
45
+ """Return the latest public package version without sending local configuration."""
46
+ response = get(REGISTRY_URL, timeout=timeout)
47
+ response.raise_for_status()
48
+ payload = response.json()
49
+ version = str(payload.get("version") or "").strip() if isinstance(payload, dict) else ""
50
+ if not version:
51
+ raise ValueError("O registry NPM não devolveu uma versão válida.")
52
+ return version
53
+
54
+
55
+ def check_version(current_version: str, *, timeout: int = 8, get: Callable[..., Any] = requests.get) -> VersionCheck:
56
+ """Fetch only the package metadata needed for the version badge."""
57
+ current = str(current_version or "").strip()
58
+ try:
59
+ return VersionCheck(current_version=current, latest_version=latest_package_version(timeout=timeout, get=get))
60
+ except (requests.RequestException, ValueError) as exc:
61
+ return VersionCheck(current_version=current, error=f"Não foi possível verificar actualizações agora ({type(exc).__name__}).")
62
+
63
+
64
+ def update_command() -> list[str]:
65
+ """Build the same cross-platform install command documented for Thunderbolt."""
66
+ executable = "npx.cmd" if os.name == "nt" else "npx"
67
+ return [executable, "--yes", "--prefer-online", PACKAGE_NAME, "install"]
68
+
69
+
70
+ def update_to_latest(
71
+ current_version: str,
72
+ *,
73
+ timeout: int = UPDATE_TIMEOUT_SECONDS,
74
+ get: Callable[..., Any] = requests.get,
75
+ run: Callable[..., Any] = subprocess.run,
76
+ ) -> UpdateResult:
77
+ """Install the latest package only after an explicit UI action.
78
+
79
+ The running local process keeps its current code until it is restarted. No
80
+ subprocess output is returned to the UI, preventing accidental display of
81
+ environment values from third-party installers.
82
+ """
83
+ status = check_version(current_version, get=get)
84
+ if status.error:
85
+ return UpdateResult(False, message=status.error)
86
+ if not status.update_available:
87
+ return UpdateResult(True, latest_version=status.latest_version, message="O Thunderbolt já está na versão mais recente.")
88
+ try:
89
+ completed = run(
90
+ update_command(),
91
+ stdin=subprocess.DEVNULL,
92
+ stdout=subprocess.DEVNULL,
93
+ stderr=subprocess.DEVNULL,
94
+ timeout=timeout,
95
+ check=False,
96
+ )
97
+ except (OSError, subprocess.TimeoutExpired) as exc:
98
+ return UpdateResult(False, latest_version=status.latest_version, message=f"Não foi possível concluir a actualização ({type(exc).__name__}).")
99
+ if int(getattr(completed, "returncode", 1)) != 0:
100
+ return UpdateResult(False, latest_version=status.latest_version, message="A actualização não foi concluída. Feche processos Thunderbolt em execução e tente novamente.")
101
+ return UpdateResult(
102
+ True,
103
+ latest_version=status.latest_version,
104
+ message=(
105
+ f"A versão {status.latest_version} foi instalada. Reinicie o Thunderbolt para abrir a versão nova; "
106
+ "os dados e configurações locais foram preservados."
107
+ ),
108
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.96",
3
+ "version": "0.3.98",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",