aline-ai 0.5.12__py3-none-any.whl → 0.6.0__py3-none-any.whl
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.
- {aline_ai-0.5.12.dist-info → aline_ai-0.6.0.dist-info}/METADATA +1 -1
- {aline_ai-0.5.12.dist-info → aline_ai-0.6.0.dist-info}/RECORD +25 -23
- realign/__init__.py +1 -1
- realign/auth.py +539 -0
- realign/cli.py +272 -1
- realign/commands/auth.py +343 -0
- realign/commands/export_shares.py +44 -21
- realign/commands/import_shares.py +16 -10
- realign/commands/init.py +10 -33
- realign/commands/watcher.py +19 -16
- realign/commands/worker.py +8 -0
- realign/config.py +12 -29
- realign/dashboard/widgets/config_panel.py +177 -1
- realign/dashboard/widgets/events_table.py +44 -5
- realign/dashboard/widgets/sessions_table.py +76 -16
- realign/db/base.py +28 -11
- realign/db/schema.py +102 -15
- realign/db/sqlite_db.py +108 -58
- realign/watcher_core.py +1 -9
- realign/watcher_daemon.py +11 -0
- realign/worker_daemon.py +11 -0
- {aline_ai-0.5.12.dist-info → aline_ai-0.6.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.5.12.dist-info → aline_ai-0.6.0.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.12.dist-info → aline_ai-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.5.12.dist-info → aline_ai-0.6.0.dist-info}/top_level.txt +0 -0
realign/cli.py
CHANGED
|
@@ -7,7 +7,7 @@ from typing import Optional
|
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
from rich.syntax import Syntax
|
|
9
9
|
|
|
10
|
-
from .commands import init, config, watcher, worker, export_shares, search, upgrade, restore, add
|
|
10
|
+
from .commands import init, config, watcher, worker, export_shares, search, upgrade, restore, add, auth
|
|
11
11
|
|
|
12
12
|
app = typer.Typer(
|
|
13
13
|
name="realign",
|
|
@@ -33,6 +33,26 @@ def main(
|
|
|
33
33
|
ctx.obj["dev"] = dev
|
|
34
34
|
|
|
35
35
|
if ctx.invoked_subcommand is None:
|
|
36
|
+
# Check login status before launching dashboard
|
|
37
|
+
from .auth import is_logged_in, get_current_user
|
|
38
|
+
|
|
39
|
+
if not is_logged_in():
|
|
40
|
+
console.print("[yellow]You need to login before using Aline.[/yellow]")
|
|
41
|
+
console.print("Starting login flow...\n")
|
|
42
|
+
|
|
43
|
+
# Run login command
|
|
44
|
+
exit_code = auth.login_command()
|
|
45
|
+
if exit_code != 0:
|
|
46
|
+
console.print("\n[red]Login failed. Please try again with 'aline login'.[/red]")
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
|
|
49
|
+
# Verify login succeeded
|
|
50
|
+
if not is_logged_in():
|
|
51
|
+
console.print("\n[red]Login verification failed. Please try again.[/red]")
|
|
52
|
+
raise typer.Exit(code=1)
|
|
53
|
+
|
|
54
|
+
console.print() # Add spacing before dashboard launch
|
|
55
|
+
|
|
36
56
|
# Check for updates before launching dashboard
|
|
37
57
|
from .commands.upgrade import check_and_prompt_update
|
|
38
58
|
|
|
@@ -57,6 +77,257 @@ app.command(name="config")(config.config_command)
|
|
|
57
77
|
app.command(name="upgrade")(upgrade.upgrade_command)
|
|
58
78
|
|
|
59
79
|
|
|
80
|
+
# Auth commands
|
|
81
|
+
@app.command(name="login")
|
|
82
|
+
def login_cli():
|
|
83
|
+
"""Login to Aline via web browser to enable share features."""
|
|
84
|
+
exit_code = auth.login_command()
|
|
85
|
+
raise typer.Exit(code=exit_code)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command(name="logout")
|
|
89
|
+
def logout_cli():
|
|
90
|
+
"""Logout from Aline and clear local credentials."""
|
|
91
|
+
exit_code = auth.logout_command()
|
|
92
|
+
raise typer.Exit(code=exit_code)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command(name="whoami")
|
|
96
|
+
def whoami_cli():
|
|
97
|
+
"""Display current login status and user information."""
|
|
98
|
+
exit_code = auth.whoami_command()
|
|
99
|
+
raise typer.Exit(code=exit_code)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command(name="doctor")
|
|
103
|
+
def doctor_cli(
|
|
104
|
+
no_restart: bool = typer.Option(False, "--no-restart", help="Only clear cache, don't restart daemons"),
|
|
105
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
|
106
|
+
):
|
|
107
|
+
"""
|
|
108
|
+
Fix common issues after code updates.
|
|
109
|
+
|
|
110
|
+
This command:
|
|
111
|
+
- Clears Python bytecode cache (.pyc files)
|
|
112
|
+
- Updates Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest)
|
|
113
|
+
- Updates skills to latest version
|
|
114
|
+
- Restarts the watcher daemon (if running)
|
|
115
|
+
- Restarts the worker daemon (if running)
|
|
116
|
+
|
|
117
|
+
Run this after pulling new code to ensure everything uses the latest version.
|
|
118
|
+
"""
|
|
119
|
+
import shutil
|
|
120
|
+
import subprocess
|
|
121
|
+
import signal
|
|
122
|
+
import time
|
|
123
|
+
|
|
124
|
+
# Find the project root (where src/realign is)
|
|
125
|
+
project_root = Path(__file__).parent.parent.parent
|
|
126
|
+
|
|
127
|
+
# 1. Clear Python cache
|
|
128
|
+
console.print("[bold]1. Clearing Python cache...[/bold]")
|
|
129
|
+
pyc_count = 0
|
|
130
|
+
pycache_count = 0
|
|
131
|
+
|
|
132
|
+
for pyc_file in project_root.rglob("*.pyc"):
|
|
133
|
+
try:
|
|
134
|
+
pyc_file.unlink()
|
|
135
|
+
pyc_count += 1
|
|
136
|
+
if verbose:
|
|
137
|
+
console.print(f" [dim]Removed: {pyc_file}[/dim]")
|
|
138
|
+
except Exception as e:
|
|
139
|
+
if verbose:
|
|
140
|
+
console.print(f" [yellow]Failed to remove {pyc_file}: {e}[/yellow]")
|
|
141
|
+
|
|
142
|
+
for pycache_dir in project_root.rglob("__pycache__"):
|
|
143
|
+
if pycache_dir.is_dir():
|
|
144
|
+
try:
|
|
145
|
+
shutil.rmtree(pycache_dir)
|
|
146
|
+
pycache_count += 1
|
|
147
|
+
if verbose:
|
|
148
|
+
console.print(f" [dim]Removed: {pycache_dir}[/dim]")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
if verbose:
|
|
151
|
+
console.print(f" [yellow]Failed to remove {pycache_dir}: {e}[/yellow]")
|
|
152
|
+
|
|
153
|
+
console.print(f" [green]✓[/green] Cleared {pyc_count} .pyc files, {pycache_count} __pycache__ directories")
|
|
154
|
+
|
|
155
|
+
# 2. Update Claude Code hooks
|
|
156
|
+
console.print("\n[bold]2. Updating Claude Code hooks...[/bold]")
|
|
157
|
+
hooks_updated = []
|
|
158
|
+
hooks_failed = []
|
|
159
|
+
|
|
160
|
+
# Stop hook
|
|
161
|
+
try:
|
|
162
|
+
from .claude_hooks.stop_hook_installer import install_stop_hook, get_settings_path
|
|
163
|
+
if install_stop_hook(get_settings_path(), quiet=True, force=True):
|
|
164
|
+
hooks_updated.append("Stop")
|
|
165
|
+
if verbose:
|
|
166
|
+
console.print(" [dim]Stop hook updated[/dim]")
|
|
167
|
+
else:
|
|
168
|
+
hooks_failed.append("Stop")
|
|
169
|
+
except Exception as e:
|
|
170
|
+
hooks_failed.append("Stop")
|
|
171
|
+
if verbose:
|
|
172
|
+
console.print(f" [yellow]Stop hook failed: {e}[/yellow]")
|
|
173
|
+
|
|
174
|
+
# UserPromptSubmit hook
|
|
175
|
+
try:
|
|
176
|
+
from .claude_hooks.user_prompt_submit_hook_installer import install_user_prompt_submit_hook, get_settings_path as get_submit_settings_path
|
|
177
|
+
if install_user_prompt_submit_hook(get_submit_settings_path(), quiet=True, force=True):
|
|
178
|
+
hooks_updated.append("UserPromptSubmit")
|
|
179
|
+
if verbose:
|
|
180
|
+
console.print(" [dim]UserPromptSubmit hook updated[/dim]")
|
|
181
|
+
else:
|
|
182
|
+
hooks_failed.append("UserPromptSubmit")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
hooks_failed.append("UserPromptSubmit")
|
|
185
|
+
if verbose:
|
|
186
|
+
console.print(f" [yellow]UserPromptSubmit hook failed: {e}[/yellow]")
|
|
187
|
+
|
|
188
|
+
# PermissionRequest hook
|
|
189
|
+
try:
|
|
190
|
+
from .claude_hooks.permission_request_hook_installer import install_permission_request_hook, get_settings_path as get_permission_settings_path
|
|
191
|
+
if install_permission_request_hook(get_permission_settings_path(), quiet=True, force=True):
|
|
192
|
+
hooks_updated.append("PermissionRequest")
|
|
193
|
+
if verbose:
|
|
194
|
+
console.print(" [dim]PermissionRequest hook updated[/dim]")
|
|
195
|
+
else:
|
|
196
|
+
hooks_failed.append("PermissionRequest")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
hooks_failed.append("PermissionRequest")
|
|
199
|
+
if verbose:
|
|
200
|
+
console.print(f" [yellow]PermissionRequest hook failed: {e}[/yellow]")
|
|
201
|
+
|
|
202
|
+
if hooks_updated:
|
|
203
|
+
console.print(f" [green]✓[/green] Updated hooks: {', '.join(hooks_updated)}")
|
|
204
|
+
if hooks_failed:
|
|
205
|
+
console.print(f" [yellow]![/yellow] Failed hooks: {', '.join(hooks_failed)}")
|
|
206
|
+
|
|
207
|
+
# 3. Update skills
|
|
208
|
+
console.print("\n[bold]3. Updating skills...[/bold]")
|
|
209
|
+
try:
|
|
210
|
+
from .commands.add import add_skills_command
|
|
211
|
+
# Capture output by redirecting - use force=True to update
|
|
212
|
+
import io
|
|
213
|
+
import contextlib
|
|
214
|
+
|
|
215
|
+
stdout_capture = io.StringIO()
|
|
216
|
+
with contextlib.redirect_stdout(stdout_capture):
|
|
217
|
+
add_skills_command(force=True)
|
|
218
|
+
|
|
219
|
+
output = stdout_capture.getvalue()
|
|
220
|
+
# Count updated skills from output
|
|
221
|
+
updated_count = output.count("✓")
|
|
222
|
+
if updated_count > 0:
|
|
223
|
+
console.print(f" [green]✓[/green] Updated {updated_count} skill(s)")
|
|
224
|
+
else:
|
|
225
|
+
console.print(" [green]✓[/green] Skills are up to date")
|
|
226
|
+
if verbose and output.strip():
|
|
227
|
+
for line in output.strip().split("\n"):
|
|
228
|
+
console.print(f" [dim]{line}[/dim]")
|
|
229
|
+
except Exception as e:
|
|
230
|
+
console.print(f" [yellow]![/yellow] Failed to update skills: {e}")
|
|
231
|
+
|
|
232
|
+
if no_restart:
|
|
233
|
+
console.print("\n[dim]Skipping daemon restart (--no-restart)[/dim]")
|
|
234
|
+
console.print("\n[green]Done![/green] Aline is ready with the latest code.")
|
|
235
|
+
raise typer.Exit(code=0)
|
|
236
|
+
|
|
237
|
+
# 4. Restart watcher daemon
|
|
238
|
+
console.print("\n[bold]4. Checking watcher daemon...[/bold]")
|
|
239
|
+
pid_file = Path.home() / ".aline" / ".logs" / "watcher.pid"
|
|
240
|
+
watcher_was_running = False
|
|
241
|
+
|
|
242
|
+
if pid_file.exists():
|
|
243
|
+
try:
|
|
244
|
+
pid = int(pid_file.read_text().strip())
|
|
245
|
+
# Check if process is running
|
|
246
|
+
try:
|
|
247
|
+
import os
|
|
248
|
+
os.kill(pid, 0) # Signal 0 just checks if process exists
|
|
249
|
+
watcher_was_running = True
|
|
250
|
+
console.print(f" [dim]Found watcher daemon (PID {pid}), stopping...[/dim]")
|
|
251
|
+
os.kill(pid, signal.SIGTERM)
|
|
252
|
+
time.sleep(1)
|
|
253
|
+
# Force kill if still running
|
|
254
|
+
try:
|
|
255
|
+
os.kill(pid, 0)
|
|
256
|
+
os.kill(pid, signal.SIGKILL)
|
|
257
|
+
time.sleep(0.5)
|
|
258
|
+
except ProcessLookupError:
|
|
259
|
+
pass
|
|
260
|
+
except ProcessLookupError:
|
|
261
|
+
console.print(" [dim]Watcher daemon not running (stale PID file)[/dim]")
|
|
262
|
+
except Exception as e:
|
|
263
|
+
if verbose:
|
|
264
|
+
console.print(f" [yellow]Error checking watcher: {e}[/yellow]")
|
|
265
|
+
|
|
266
|
+
if watcher_was_running:
|
|
267
|
+
console.print(" [dim]Starting watcher daemon...[/dim]")
|
|
268
|
+
try:
|
|
269
|
+
subprocess.Popen(
|
|
270
|
+
["python", "-m", "src.realign.watcher_daemon"],
|
|
271
|
+
stdout=subprocess.DEVNULL,
|
|
272
|
+
stderr=subprocess.DEVNULL,
|
|
273
|
+
start_new_session=True,
|
|
274
|
+
cwd=str(project_root),
|
|
275
|
+
)
|
|
276
|
+
time.sleep(2)
|
|
277
|
+
console.print(" [green]✓[/green] Watcher daemon restarted")
|
|
278
|
+
except Exception as e:
|
|
279
|
+
console.print(f" [red]✗[/red] Failed to restart watcher: {e}")
|
|
280
|
+
else:
|
|
281
|
+
console.print(" [dim]Watcher daemon was not running[/dim]")
|
|
282
|
+
|
|
283
|
+
# 5. Restart worker daemon
|
|
284
|
+
console.print("\n[bold]5. Checking worker daemon...[/bold]")
|
|
285
|
+
worker_pid_file = Path.home() / ".aline" / ".logs" / "worker.pid"
|
|
286
|
+
worker_was_running = False
|
|
287
|
+
|
|
288
|
+
if worker_pid_file.exists():
|
|
289
|
+
try:
|
|
290
|
+
pid = int(worker_pid_file.read_text().strip())
|
|
291
|
+
try:
|
|
292
|
+
import os
|
|
293
|
+
os.kill(pid, 0)
|
|
294
|
+
worker_was_running = True
|
|
295
|
+
console.print(f" [dim]Found worker daemon (PID {pid}), stopping...[/dim]")
|
|
296
|
+
os.kill(pid, signal.SIGTERM)
|
|
297
|
+
time.sleep(1)
|
|
298
|
+
try:
|
|
299
|
+
os.kill(pid, 0)
|
|
300
|
+
os.kill(pid, signal.SIGKILL)
|
|
301
|
+
time.sleep(0.5)
|
|
302
|
+
except ProcessLookupError:
|
|
303
|
+
pass
|
|
304
|
+
except ProcessLookupError:
|
|
305
|
+
console.print(" [dim]Worker daemon not running (stale PID file)[/dim]")
|
|
306
|
+
except Exception as e:
|
|
307
|
+
if verbose:
|
|
308
|
+
console.print(f" [yellow]Error checking worker: {e}[/yellow]")
|
|
309
|
+
|
|
310
|
+
if worker_was_running:
|
|
311
|
+
console.print(" [dim]Starting worker daemon...[/dim]")
|
|
312
|
+
try:
|
|
313
|
+
subprocess.Popen(
|
|
314
|
+
["python", "-m", "src.realign.worker_daemon"],
|
|
315
|
+
stdout=subprocess.DEVNULL,
|
|
316
|
+
stderr=subprocess.DEVNULL,
|
|
317
|
+
start_new_session=True,
|
|
318
|
+
cwd=str(project_root),
|
|
319
|
+
)
|
|
320
|
+
time.sleep(2)
|
|
321
|
+
console.print(" [green]✓[/green] Worker daemon restarted")
|
|
322
|
+
except Exception as e:
|
|
323
|
+
console.print(f" [red]✗[/red] Failed to restart worker: {e}")
|
|
324
|
+
else:
|
|
325
|
+
console.print(" [dim]Worker daemon was not running[/dim]")
|
|
326
|
+
|
|
327
|
+
console.print("\n[green]Done![/green] Aline is ready with the latest code.")
|
|
328
|
+
raise typer.Exit(code=0)
|
|
329
|
+
|
|
330
|
+
|
|
60
331
|
@app.command(name="search")
|
|
61
332
|
def search_cli(
|
|
62
333
|
query: str = typer.Argument(..., help="Search query (keywords or regex pattern)"),
|
realign/commands/auth.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Authentication commands for Aline CLI.
|
|
4
|
+
|
|
5
|
+
Commands:
|
|
6
|
+
- aline login - Login via web browser
|
|
7
|
+
- aline logout - Clear local credentials
|
|
8
|
+
- aline whoami - Show current login status
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.prompt import Prompt
|
|
17
|
+
RICH_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
RICH_AVAILABLE = False
|
|
20
|
+
|
|
21
|
+
from ..auth import (
|
|
22
|
+
is_logged_in,
|
|
23
|
+
get_current_user,
|
|
24
|
+
load_credentials,
|
|
25
|
+
save_credentials,
|
|
26
|
+
clear_credentials,
|
|
27
|
+
open_login_page,
|
|
28
|
+
validate_cli_token,
|
|
29
|
+
find_free_port,
|
|
30
|
+
start_callback_server,
|
|
31
|
+
HTTPX_AVAILABLE,
|
|
32
|
+
)
|
|
33
|
+
from ..config import ReAlignConfig
|
|
34
|
+
from ..logging_config import setup_logger
|
|
35
|
+
|
|
36
|
+
logger = setup_logger("realign.commands.auth", "auth.log")
|
|
37
|
+
|
|
38
|
+
if RICH_AVAILABLE:
|
|
39
|
+
console = Console()
|
|
40
|
+
else:
|
|
41
|
+
console = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def login_command() -> int:
|
|
45
|
+
"""
|
|
46
|
+
Login to Aline via web browser.
|
|
47
|
+
|
|
48
|
+
Opens the web login page with automatic callback - no manual token copy needed.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
0 on success, 1 on error
|
|
52
|
+
"""
|
|
53
|
+
# Check dependencies
|
|
54
|
+
if not HTTPX_AVAILABLE:
|
|
55
|
+
print("Error: httpx package not installed", file=sys.stderr)
|
|
56
|
+
print("Install it with: pip install httpx", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
# Check if already logged in
|
|
60
|
+
credentials = load_credentials()
|
|
61
|
+
if credentials and is_logged_in():
|
|
62
|
+
if console:
|
|
63
|
+
console.print(f"[yellow]Already logged in as {credentials.email}[/yellow]")
|
|
64
|
+
console.print("Run 'aline logout' first if you want to switch accounts.")
|
|
65
|
+
else:
|
|
66
|
+
print(f"Already logged in as {credentials.email}")
|
|
67
|
+
print("Run 'aline logout' first if you want to switch accounts.")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
# Start local callback server
|
|
71
|
+
port = find_free_port()
|
|
72
|
+
|
|
73
|
+
if console:
|
|
74
|
+
console.print("[cyan]Opening browser for login...[/cyan]")
|
|
75
|
+
console.print("[dim]Waiting for authentication...[/dim]\n")
|
|
76
|
+
else:
|
|
77
|
+
print("Opening browser for login...")
|
|
78
|
+
print("Waiting for authentication...\n")
|
|
79
|
+
|
|
80
|
+
# Open browser with callback URL
|
|
81
|
+
login_url = open_login_page(callback_port=port)
|
|
82
|
+
|
|
83
|
+
if console:
|
|
84
|
+
console.print(f"[dim]If browser doesn't open, visit:[/dim]")
|
|
85
|
+
console.print(f"[link={login_url}]{login_url}[/link]\n")
|
|
86
|
+
else:
|
|
87
|
+
print(f"If browser doesn't open, visit:")
|
|
88
|
+
print(f"{login_url}\n")
|
|
89
|
+
|
|
90
|
+
# Wait for callback with token
|
|
91
|
+
cli_token, error = start_callback_server(port, timeout=300)
|
|
92
|
+
|
|
93
|
+
if error:
|
|
94
|
+
if console:
|
|
95
|
+
console.print(f"[red]Error: {error}[/red]")
|
|
96
|
+
else:
|
|
97
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
if not cli_token:
|
|
101
|
+
if console:
|
|
102
|
+
console.print("[red]Error: No token received[/red]")
|
|
103
|
+
console.print("Please try again with 'aline login'")
|
|
104
|
+
else:
|
|
105
|
+
print("Error: No token received", file=sys.stderr)
|
|
106
|
+
print("Please try again with 'aline login'")
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
# Validate token
|
|
110
|
+
if console:
|
|
111
|
+
console.print("[cyan]Validating token...[/cyan]")
|
|
112
|
+
else:
|
|
113
|
+
print("Validating token...")
|
|
114
|
+
|
|
115
|
+
credentials = validate_cli_token(cli_token)
|
|
116
|
+
|
|
117
|
+
if not credentials:
|
|
118
|
+
if console:
|
|
119
|
+
console.print("[red]Error: Invalid or expired token[/red]")
|
|
120
|
+
console.print("Please try again with 'aline login'")
|
|
121
|
+
else:
|
|
122
|
+
print("Error: Invalid or expired token", file=sys.stderr)
|
|
123
|
+
print("Please try again with 'aline login'")
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
# Save credentials
|
|
127
|
+
if not save_credentials(credentials):
|
|
128
|
+
if console:
|
|
129
|
+
console.print("[red]Error: Failed to save credentials[/red]")
|
|
130
|
+
else:
|
|
131
|
+
print("Error: Failed to save credentials", file=sys.stderr)
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
# Sync Supabase uid to local config
|
|
135
|
+
# This ensures all new Events/Sessions/Turns use the same uid as shares
|
|
136
|
+
try:
|
|
137
|
+
config = ReAlignConfig.load()
|
|
138
|
+
old_uid = config.uid
|
|
139
|
+
config.uid = credentials.user_id
|
|
140
|
+
# Use email as user_name if not already set
|
|
141
|
+
if not config.user_name:
|
|
142
|
+
config.user_name = credentials.email.split("@")[0] # Use email prefix as username
|
|
143
|
+
config.save()
|
|
144
|
+
logger.info(f"Synced Supabase uid to config: {credentials.user_id[:8]}... (was: {old_uid[:8] if old_uid else 'not set'}...)")
|
|
145
|
+
|
|
146
|
+
# V18: Upsert user info to users table
|
|
147
|
+
try:
|
|
148
|
+
from ..db import get_database
|
|
149
|
+
db = get_database()
|
|
150
|
+
db.upsert_user(config.uid, config.user_name)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.debug(f"Failed to upsert user to users table: {e}")
|
|
153
|
+
except Exception as e:
|
|
154
|
+
# Non-fatal: continue even if config sync fails
|
|
155
|
+
logger.warning(f"Failed to sync uid to config: {e}")
|
|
156
|
+
|
|
157
|
+
# Success
|
|
158
|
+
if console:
|
|
159
|
+
console.print(f"\n[green]Login successful![/green]")
|
|
160
|
+
console.print(f"Logged in as: [bold]{credentials.email}[/bold]")
|
|
161
|
+
if credentials.provider and credentials.provider != "email":
|
|
162
|
+
console.print(f"Provider: {credentials.provider}")
|
|
163
|
+
console.print(f"[dim]User ID synced to local config[/dim]")
|
|
164
|
+
else:
|
|
165
|
+
print(f"\nLogin successful!")
|
|
166
|
+
print(f"Logged in as: {credentials.email}")
|
|
167
|
+
if credentials.provider and credentials.provider != "email":
|
|
168
|
+
print(f"Provider: {credentials.provider}")
|
|
169
|
+
print("User ID synced to local config")
|
|
170
|
+
|
|
171
|
+
logger.info(f"Login successful for {credentials.email}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def logout_command() -> int:
|
|
176
|
+
"""
|
|
177
|
+
Logout from Aline and clear local credentials.
|
|
178
|
+
|
|
179
|
+
Also stops watcher and worker daemons since they require authentication.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
0 on success, 1 on error
|
|
183
|
+
"""
|
|
184
|
+
credentials = load_credentials()
|
|
185
|
+
|
|
186
|
+
if not credentials:
|
|
187
|
+
if console:
|
|
188
|
+
console.print("[yellow]Not currently logged in.[/yellow]")
|
|
189
|
+
else:
|
|
190
|
+
print("Not currently logged in.")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
email = credentials.email
|
|
194
|
+
|
|
195
|
+
# Stop watcher and worker daemons before logout
|
|
196
|
+
if console:
|
|
197
|
+
console.print("[dim]Stopping daemons...[/dim]")
|
|
198
|
+
else:
|
|
199
|
+
print("Stopping daemons...")
|
|
200
|
+
|
|
201
|
+
_stop_daemons()
|
|
202
|
+
|
|
203
|
+
if not clear_credentials():
|
|
204
|
+
if console:
|
|
205
|
+
console.print("[red]Error: Failed to clear credentials[/red]")
|
|
206
|
+
else:
|
|
207
|
+
print("Error: Failed to clear credentials", file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
|
|
210
|
+
if console:
|
|
211
|
+
console.print(f"[green]Logged out successfully.[/green]")
|
|
212
|
+
console.print(f"Cleared credentials for: {email}")
|
|
213
|
+
else:
|
|
214
|
+
print("Logged out successfully.")
|
|
215
|
+
print(f"Cleared credentials for: {email}")
|
|
216
|
+
|
|
217
|
+
logger.info(f"Logout successful for {email}")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _stop_daemons() -> None:
|
|
222
|
+
"""Stop watcher and worker daemons."""
|
|
223
|
+
import os
|
|
224
|
+
import signal
|
|
225
|
+
import time
|
|
226
|
+
from pathlib import Path
|
|
227
|
+
|
|
228
|
+
def stop_daemon(name: str, pid_file: Path) -> None:
|
|
229
|
+
"""Stop a daemon by PID file, wait for it to terminate."""
|
|
230
|
+
if not pid_file.exists():
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
pid = int(pid_file.read_text().strip())
|
|
235
|
+
except Exception:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Check if process is running
|
|
239
|
+
try:
|
|
240
|
+
os.kill(pid, 0)
|
|
241
|
+
except ProcessLookupError:
|
|
242
|
+
# Process already gone, clean up PID file
|
|
243
|
+
try:
|
|
244
|
+
pid_file.unlink(missing_ok=True)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
return
|
|
248
|
+
except PermissionError:
|
|
249
|
+
pass # Can't check, try to stop anyway
|
|
250
|
+
|
|
251
|
+
# Send SIGTERM
|
|
252
|
+
try:
|
|
253
|
+
os.kill(pid, signal.SIGTERM)
|
|
254
|
+
if console:
|
|
255
|
+
console.print(f" [dim]Stopping {name} daemon (PID {pid})...[/dim]")
|
|
256
|
+
logger.info(f"Sent SIGTERM to {name} daemon (PID {pid})")
|
|
257
|
+
except ProcessLookupError:
|
|
258
|
+
try:
|
|
259
|
+
pid_file.unlink(missing_ok=True)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
return
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.debug(f"Error sending SIGTERM to {name}: {e}")
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# Wait for process to terminate (up to 5 seconds)
|
|
268
|
+
for _ in range(50): # 50 * 0.1s = 5 seconds
|
|
269
|
+
time.sleep(0.1)
|
|
270
|
+
try:
|
|
271
|
+
os.kill(pid, 0)
|
|
272
|
+
except ProcessLookupError:
|
|
273
|
+
# Process terminated
|
|
274
|
+
if console:
|
|
275
|
+
console.print(f" [dim]{name.capitalize()} daemon stopped[/dim]")
|
|
276
|
+
logger.info(f"{name.capitalize()} daemon (PID {pid}) stopped")
|
|
277
|
+
try:
|
|
278
|
+
pid_file.unlink(missing_ok=True)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
return
|
|
282
|
+
except PermissionError:
|
|
283
|
+
break # Can't check anymore
|
|
284
|
+
|
|
285
|
+
# Process still running, try SIGKILL
|
|
286
|
+
try:
|
|
287
|
+
os.kill(pid, signal.SIGKILL)
|
|
288
|
+
time.sleep(0.5)
|
|
289
|
+
if console:
|
|
290
|
+
console.print(f" [dim]{name.capitalize()} daemon force killed[/dim]")
|
|
291
|
+
logger.info(f"{name.capitalize()} daemon (PID {pid}) force killed")
|
|
292
|
+
except ProcessLookupError:
|
|
293
|
+
pass
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.debug(f"Error sending SIGKILL to {name}: {e}")
|
|
296
|
+
|
|
297
|
+
# Clean up PID file
|
|
298
|
+
try:
|
|
299
|
+
pid_file.unlink(missing_ok=True)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
# Stop watcher daemon
|
|
304
|
+
watcher_pid_file = Path.home() / ".aline" / ".logs" / "watcher.pid"
|
|
305
|
+
stop_daemon("watcher", watcher_pid_file)
|
|
306
|
+
|
|
307
|
+
# Stop worker daemon
|
|
308
|
+
worker_pid_file = Path.home() / ".aline" / ".logs" / "worker.pid"
|
|
309
|
+
stop_daemon("worker", worker_pid_file)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def whoami_command() -> int:
|
|
313
|
+
"""
|
|
314
|
+
Display current login status.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
0 if logged in, 1 if not logged in
|
|
318
|
+
"""
|
|
319
|
+
credentials = get_current_user()
|
|
320
|
+
|
|
321
|
+
if not credentials:
|
|
322
|
+
if console:
|
|
323
|
+
console.print("[yellow]Not logged in.[/yellow]")
|
|
324
|
+
console.print("Run 'aline login' to authenticate.")
|
|
325
|
+
else:
|
|
326
|
+
print("Not logged in.")
|
|
327
|
+
print("Run 'aline login' to authenticate.")
|
|
328
|
+
return 1
|
|
329
|
+
|
|
330
|
+
if console:
|
|
331
|
+
console.print(f"[green]Logged in as:[/green] [bold]{credentials.email}[/bold]")
|
|
332
|
+
console.print(f"[dim]User ID:[/dim] {credentials.user_id}")
|
|
333
|
+
if credentials.provider:
|
|
334
|
+
console.print(f"[dim]Provider:[/dim] {credentials.provider}")
|
|
335
|
+
console.print(f"[dim]Token expires:[/dim] {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
336
|
+
else:
|
|
337
|
+
print(f"Logged in as: {credentials.email}")
|
|
338
|
+
print(f"User ID: {credentials.user_id}")
|
|
339
|
+
if credentials.provider:
|
|
340
|
+
print(f"Provider: {credentials.provider}")
|
|
341
|
+
print(f"Token expires: {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
342
|
+
|
|
343
|
+
return 0
|