Skip to content

Commit 289d357

Browse files
feat(installation): add update-only mode and enhance existing installation detection
- Introduced an update-only mode in the installation process, allowing users to skip configuration saving if an existing installation is detected. - Enhanced the welcome screen to detect existing installations and provide options for updating or starting a new setup. - Updated installation completion messages to reflect whether the installation was a fresh setup or an update.
1 parent 449e736 commit 289d357

File tree

2 files changed

+215
-6
lines changed

2 files changed

+215
-6
lines changed

src/thoth/cli/setup/screens/installation.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def __init__(self) -> None:
2929
)
3030
self.vault_path: Path | None = None
3131
self.installation_complete = False
32+
self.update_only = False
3233
self.installation_steps = [
3334
'Creating workspace directory',
3435
'Saving configuration',
@@ -44,6 +45,11 @@ def on_mount(self) -> None:
4445
# Get vault path from wizard
4546
if hasattr(self.app, 'wizard_data'):
4647
self.vault_path = self.app.wizard_data.get('vault_path')
48+
self.update_only = self.app.wizard_data.get('update_only', False)
49+
50+
# Update step labels for update-only mode
51+
if self.update_only:
52+
self.installation_steps[1] = 'Keeping existing configuration'
4753

4854
# Start installation automatically
4955
self._install_task = asyncio.create_task(self.run_installation())
@@ -87,12 +93,16 @@ async def run_installation(self) -> None:
8793
self._update_step(1, 'done')
8894
await asyncio.sleep(0.3)
8995

90-
# Step 2: Save configuration
96+
# Step 2: Save configuration (skip if update_only)
9197
self.current_step = 2
9298
self._update_step(2, 'active')
9399
status_text.update(f'[cyan]{self.installation_steps[1]}...[/cyan]')
94100
progress_bar.update(progress=40)
95-
await self.save_configuration()
101+
if self.update_only:
102+
logger.info('Update-only mode: skipping configuration save')
103+
await asyncio.sleep(0.3) # Brief pause for UI consistency
104+
else:
105+
await self.save_configuration()
96106
self._update_step(2, 'done')
97107
await asyncio.sleep(0.3)
98108

@@ -126,12 +136,16 @@ async def run_installation(self) -> None:
126136
# Complete - commit transaction
127137
self.transaction.commit()
128138
progress_bar.update(progress=100)
129-
status_text.update('[bold green]✓ Installation complete![/bold green]')
139+
140+
completion_msg = (
141+
'Thoth updated successfully!'
142+
if self.update_only
143+
else 'Thoth installed successfully!'
144+
)
145+
status_text.update(f'[bold green]✓ {completion_msg}[/bold green]')
130146
self.installation_complete = True
131147
self.clear_messages()
132-
self.show_success(
133-
'Thoth installed successfully! Press Next → to finish setup.'
134-
)
148+
self.show_success(f'{completion_msg} Press Next → to finish setup.')
135149

136150
# Show the Next button now
137151
self._show_next_button()

src/thoth/cli/setup/screens/welcome.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from __future__ import annotations
88

9+
import os
10+
from pathlib import Path
911
from typing import Any
1012

1113
from loguru import logger
@@ -15,6 +17,35 @@
1517
from .base import BaseScreen
1618

1719

20+
def _is_docker_setup() -> bool:
21+
"""Check if running inside the Docker setup container."""
22+
return os.environ.get('THOTH_DOCKER_SETUP') == '1'
23+
24+
25+
def _host_to_container_path(host_path: Path) -> Path:
26+
"""Translate a host filesystem path to the equivalent container path."""
27+
host_home = os.environ.get('THOTH_HOST_HOME', '')
28+
if not host_home:
29+
return host_path
30+
container_home = str(Path.home())
31+
path_str = str(host_path)
32+
if path_str.startswith(host_home):
33+
return Path(container_home + path_str[len(host_home) :])
34+
return host_path
35+
36+
37+
def _container_to_host_path(container_path: Path) -> Path:
38+
"""Translate a container path back to the host filesystem path."""
39+
host_home = os.environ.get('THOTH_HOST_HOME', '')
40+
if not host_home:
41+
return container_path
42+
container_home = str(Path.home())
43+
path_str = str(container_path)
44+
if path_str.startswith(container_home):
45+
return Path(host_home + path_str[len(container_home) :])
46+
return container_path
47+
48+
1849
class WelcomeScreen(BaseScreen):
1950
"""Welcome screen for Thoth setup wizard."""
2051

@@ -24,6 +55,116 @@ def __init__(self) -> None:
2455
title='Welcome to Thoth Setup',
2556
subtitle='AI-Powered Research Assistant for Obsidian',
2657
)
58+
self.existing_config: dict[str, Any] | None = None
59+
self.has_existing_installation = False
60+
61+
def on_mount(self) -> None:
62+
"""Run when screen is mounted - detect existing installation."""
63+
self._detect_existing_installation()
64+
65+
def _detect_existing_installation(self) -> None:
66+
"""Check if there's already a valid Thoth installation."""
67+
vault_path_str = os.environ.get('OBSIDIAN_VAULT_PATH', '').strip()
68+
if not vault_path_str:
69+
logger.info('No OBSIDIAN_VAULT_PATH in environment')
70+
return
71+
72+
try:
73+
# Resolve the vault path
74+
vault_path = Path(vault_path_str).expanduser().resolve()
75+
76+
# Handle Docker path translation
77+
if _is_docker_setup() and not vault_path.exists():
78+
vault_path = _host_to_container_path(vault_path)
79+
80+
if not vault_path.exists() or not vault_path.is_dir():
81+
logger.info(f'Vault path does not exist: {vault_path}')
82+
return
83+
84+
# Check for settings.json (new and legacy locations)
85+
settings_path = vault_path / 'thoth' / '_thoth' / 'settings.json'
86+
legacy_path = vault_path / '_thoth' / 'settings.json'
87+
88+
if not settings_path.exists() and legacy_path.exists():
89+
settings_path = legacy_path
90+
91+
if not settings_path.exists():
92+
logger.info(f'No settings.json found in vault: {vault_path}')
93+
return
94+
95+
# Try to load settings with Pydantic validation
96+
from thoth.config import Settings
97+
98+
settings = Settings.from_json_file(settings_path)
99+
100+
# If we got here, we have a valid config
101+
self.has_existing_installation = True
102+
103+
# Store config info for the update path
104+
vault_path_host = vault_path
105+
if _is_docker_setup():
106+
vault_path_host = _container_to_host_path(vault_path)
107+
108+
self.existing_config = {
109+
'vault_path': vault_path,
110+
'vault_path_host': vault_path_host,
111+
'settings_path': settings_path,
112+
'settings': settings,
113+
'version': settings.version or 'unknown',
114+
}
115+
116+
logger.info(
117+
f'Detected existing installation at {vault_path} (version: {settings.version})'
118+
)
119+
120+
# Update UI to show the update option
121+
self._show_update_option()
122+
123+
except Exception as e:
124+
logger.debug(f'Error detecting existing installation: {e}')
125+
# Not fatal - user can still do full setup
126+
127+
def _show_update_option(self) -> None:
128+
"""Update the UI to show the update option."""
129+
if not self.existing_config:
130+
return
131+
132+
try:
133+
# Update the welcome text to show detected config
134+
welcome_widget = self.query_one('.welcome-content', Static)
135+
vault_display = self.existing_config['vault_path_host']
136+
version = self.existing_config['version']
137+
138+
new_text = f"""
139+
[bold cyan]Welcome to Thoth![/bold cyan]
140+
141+
[bold green]Existing installation detected![/bold green]
142+
143+
Vault: [cyan]{vault_display}[/cyan]
144+
Version: [cyan]{version}[/cyan]
145+
146+
[bold]Choose how to proceed:[/bold]
147+
148+
• [yellow]Update Software[/yellow] - Keep your settings, update templates & plugin
149+
• [cyan]Begin Setup[/cyan] - Reconfigure everything (will preserve existing files)
150+
151+
[bold cyan]Navigation:[/bold cyan]
152+
• [dim]Tab to switch between buttons[/dim]
153+
• [dim]Enter to select[/dim]
154+
• [dim]ESC to exit wizard[/dim]
155+
"""
156+
157+
welcome_widget.update(new_text.strip())
158+
159+
# Show the update button
160+
try:
161+
update_btn = self.query_one('#update', Button)
162+
update_btn.styles.display = 'block'
163+
except Exception:
164+
pass # Button might not exist yet
165+
166+
except Exception as e:
167+
logger.debug(f'Could not update UI: {e}')
27168

28169
def compose_content(self) -> ComposeResult:
29170
"""
@@ -76,8 +217,62 @@ def compose_buttons(self) -> ComposeResult:
76217
Button widgets
77218
"""
78219
yield Button('Exit', id='cancel', variant='error')
220+
221+
# Update button (hidden by default, shown when existing config detected)
222+
update_btn = Button('Update Software', id='update', variant='primary')
223+
update_btn.styles.display = 'none'
224+
yield update_btn
225+
79226
yield Button('Begin Setup', id='next', variant='success')
80227

228+
async def on_button_pressed(self, event: Button.Pressed) -> None:
229+
"""Handle button press events.
230+
231+
Args:
232+
event: Button pressed event
233+
"""
234+
button_id = event.button.id
235+
236+
if button_id == 'update':
237+
await self._handle_update_path()
238+
else:
239+
# Delegate to base class for cancel/next
240+
await super().on_button_pressed(event)
241+
242+
async def _handle_update_path(self) -> None:
243+
"""Handle the update-only path - skip config and go to installation."""
244+
if not self.existing_config:
245+
self.show_error('No existing configuration detected')
246+
return
247+
248+
logger.info('User chose update-only path')
249+
250+
# Extract paths from existing settings
251+
settings = self.existing_config['settings']
252+
paths_config = {
253+
'workspace': settings.paths.workspace,
254+
'pdf': settings.paths.pdf,
255+
'markdown': settings.paths.markdown,
256+
'notes': settings.paths.notes,
257+
}
258+
259+
# Populate wizard_data with minimal info needed for installation
260+
if hasattr(self.app, 'wizard_data'):
261+
self.app.wizard_data.update(
262+
{
263+
'vault_path': self.existing_config['vault_path'],
264+
'vault_path_host': self.existing_config['vault_path_host'],
265+
'paths_config': paths_config,
266+
'update_only': True,
267+
}
268+
)
269+
270+
# Jump directly to installation
271+
from .installation import InstallationScreen
272+
273+
logger.info('Jumping to installation for update-only')
274+
await self.app.push_screen(InstallationScreen())
275+
81276
async def validate_and_proceed(self) -> dict[str, Any] | None:
82277
"""
83278
Validate welcome screen (always passes).

0 commit comments

Comments
 (0)