aline-ai 0.5.12__py3-none-any.whl → 0.5.13__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.5.13.dist-info}/METADATA +1 -1
- {aline_ai-0.5.12.dist-info → aline_ai-0.5.13.dist-info}/RECORD +20 -18
- realign/__init__.py +1 -1
- realign/auth.py +539 -0
- realign/cli.py +23 -1
- realign/commands/auth.py +242 -0
- realign/commands/export_shares.py +44 -21
- realign/commands/import_shares.py +10 -10
- realign/commands/init.py +10 -33
- realign/commands/watcher.py +11 -16
- realign/config.py +12 -29
- realign/dashboard/widgets/config_panel.py +177 -1
- 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
- {aline_ai-0.5.12.dist-info → aline_ai-0.5.13.dist-info}/WHEEL +0 -0
- {aline_ai-0.5.12.dist-info → aline_ai-0.5.13.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.12.dist-info → aline_ai-0.5.13.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.5.12.dist-info → aline_ai-0.5.13.dist-info}/top_level.txt +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
aline_ai-0.5.
|
|
2
|
-
realign/__init__.py,sha256=
|
|
1
|
+
aline_ai-0.5.13.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
|
|
2
|
+
realign/__init__.py,sha256=7hBPixwKhL2BzvTWOw-rerx1yBjnC8f5kVd0IDyl-Bo,1624
|
|
3
|
+
realign/auth.py,sha256=63fdy-KsNoLZ9A6X0Mz_v-0tQOXN_1XncXBGBlEoXqE,16030
|
|
3
4
|
realign/claude_detector.py,sha256=ZLSJacMo6zzQclXByABKA70UNpstxqIv3fPGqdpA934,2792
|
|
4
|
-
realign/cli.py,sha256=
|
|
5
|
+
realign/cli.py,sha256=RuFw_FWnKcRm0-3J-pGNR-S9e0D1rZ-ptAPlO2FFOlM,31807
|
|
5
6
|
realign/codex_detector.py,sha256=N9ulgMgvTzDfXE4s4vLd6OoS0hT7R6h2bDFFXWa-2hE,4183
|
|
6
|
-
realign/config.py,sha256=
|
|
7
|
+
realign/config.py,sha256=d8HQ6v1xju6cjNGbR7LlfHaMyvFPcMDofhGbepxpQq8,11634
|
|
7
8
|
realign/context.py,sha256=8hzgNOg-7_eMW22wt7OM5H9IsmMveKXCv0epG7E0G7w,13917
|
|
8
9
|
realign/file_lock.py,sha256=kLNm1Rra4TCrTMyPM5fwjVascq-CUz2Bzh9HHKtCKOE,3444
|
|
9
10
|
realign/hooks.py,sha256=NR4LgWgkA6npW_B68I7OdCaZNWseYSP7ZbK4Sl5nnTo,74692
|
|
@@ -12,7 +13,7 @@ realign/logging_config.py,sha256=LCAigKFhTj86PSJm4-kUl3Ag9h_GENh3x2iPnMv7qUI,487
|
|
|
12
13
|
realign/mcp_server.py,sha256=LWiQ2qukYoNLsoV2ID2f0vF9jkJlBvB587HpM5jymgE,10193
|
|
13
14
|
realign/mcp_watcher.py,sha256=aK4jWStv7CoCroS4tXFHgZ_y_-q4QDjrpWgm4DxcEj4,1260
|
|
14
15
|
realign/redactor.py,sha256=Zsoi5HfYak2yPmck20JArhm-1cPSB78IdkBJiNVXfrc,17096
|
|
15
|
-
realign/watcher_core.py,sha256=
|
|
16
|
+
realign/watcher_core.py,sha256=NNn_xlm50Ybb60--DrF9dyvzGgJ4ENQeAUbZsH7w4to,106555
|
|
16
17
|
realign/watcher_daemon.py,sha256=AVOMXrlVVy7Rlx3Yfib4e-KLszIR7CLdSHpdoxDRp8c,3090
|
|
17
18
|
realign/worker_core.py,sha256=-GOItHE0vzExB8LZK6KeHx4tt_mIqtCoUljOtEg2x8A,10105
|
|
18
19
|
realign/worker_daemon.py,sha256=LpJbQDY0Z4AMtq0LmpxvFeQM4puuoGDRBayKRafvKhc,3574
|
|
@@ -33,15 +34,16 @@ realign/claude_hooks/user_prompt_submit_hook.py,sha256=WD-UavhBTueN2TPfnZrnPC7DF
|
|
|
33
34
|
realign/claude_hooks/user_prompt_submit_hook_installer.py,sha256=2xLF8yZcE7Iwib9gU-xCkA1NWxNH9Nc5CFKPYK7rtXw,5371
|
|
34
35
|
realign/commands/__init__.py,sha256=sx_ck55oxaoiF4N3LugG0ZXwonUDxeEZ5uHbBKCC7K8,89
|
|
35
36
|
realign/commands/add.py,sha256=njZgg3paUmOw-sb-sWkXr_eUaf5bD-hBEiRectaphPs,24332
|
|
37
|
+
realign/commands/auth.py,sha256=AnUXpnRBkdBflFIweUz5WQKFVmtyxWRjwvw-96w0rRw,7625
|
|
36
38
|
realign/commands/config.py,sha256=nYnu_h2pk7GODcrzrV04K51D-s7v06FlRXHJ0HJ-gvU,6732
|
|
37
39
|
realign/commands/context.py,sha256=pM2KfZHVkB-ou4nBhFvKSwnYliLBzwN3zerLyBAbhfE,7095
|
|
38
|
-
realign/commands/export_shares.py,sha256=
|
|
39
|
-
realign/commands/import_shares.py,sha256=
|
|
40
|
-
realign/commands/init.py,sha256=
|
|
40
|
+
realign/commands/export_shares.py,sha256=g2SxlPEb7FPMarjTZcoZntviZo5JdqEe-M28vggc2-s,136601
|
|
41
|
+
realign/commands/import_shares.py,sha256=4_Bzf9IgSpK2oOLoYWEnF1BmpXZaB5ijvpUQPeCtIfg,25386
|
|
42
|
+
realign/commands/init.py,sha256=nhP1Qjl6Xo5R1ry_iTGVu3RwMxP-pYT5Z50NdzEMKrY,32756
|
|
41
43
|
realign/commands/restore.py,sha256=s2BxQZHxQw9r12NzRVsK20KlGafy5AIoSjWMo5PcnHY,11173
|
|
42
44
|
realign/commands/search.py,sha256=QJrC0hln9sCDFxXbpo0nPGMHXrud18qA5QfRyD0z6fQ,25926
|
|
43
45
|
realign/commands/upgrade.py,sha256=L3PLOUIN5qAQTbkfoVtSsIbbzEezA_xjjk9F1GMVfjw,12781
|
|
44
|
-
realign/commands/watcher.py,sha256=
|
|
46
|
+
realign/commands/watcher.py,sha256=VunN3deqE1_DLW9UcFgLj_MFX8jNkVr2Ra7aNmWG9xA,136006
|
|
45
47
|
realign/commands/worker.py,sha256=K1DG1uZ--ebKwklHCyIFdN_axoLjL9Onx8Naq-DOZBs,23078
|
|
46
48
|
realign/dashboard/__init__.py,sha256=QZkHTsGityH8UkF8rmvA3xW7dMXNe0swEWr443qfgCM,128
|
|
47
49
|
realign/dashboard/app.py,sha256=jyW6mqmItTy253CPSqInxctkWzkrGEikdy-ikuShQ14,13299
|
|
@@ -55,7 +57,7 @@ realign/dashboard/screens/session_detail.py,sha256=gfpUIhMO00ecMlMyzpkxDdvGb9zhE
|
|
|
55
57
|
realign/dashboard/screens/share_import.py,sha256=hl2x0yGVycsoUI76AmdZTAV-br3Q6191g5xHHrZ8hOA,6318
|
|
56
58
|
realign/dashboard/styles/dashboard.tcss,sha256=ewonevBGLN-dfSsgxUk4VBCPchtxY4rx_vj1u6Ox2Fw,3454
|
|
57
59
|
realign/dashboard/widgets/__init__.py,sha256=3Pf2_K9obrertgv_psfxradgkI9RXlmjoXYQH7oBKm0,583
|
|
58
|
-
realign/dashboard/widgets/config_panel.py,sha256=
|
|
60
|
+
realign/dashboard/widgets/config_panel.py,sha256=JRv9Hgm5V-vqVptS7gQqnjg5MbKpt_FmdZV13D4E9A4,17894
|
|
59
61
|
realign/dashboard/widgets/events_table.py,sha256=dXN_aD94YJ1fDSV2B_5m7YMvMU3bNUhGFCMIRWvvLMg,31141
|
|
60
62
|
realign/dashboard/widgets/header.py,sha256=0HHCFXX7F3C6HII-WDwOJwWkJrajmKPWmdoMWyOkn9E,1587
|
|
61
63
|
realign/dashboard/widgets/openable_table.py,sha256=GeJPDEYp0kRHShqvmPMzAePpYXRZHUNqcWNnxqsqxjA,1963
|
|
@@ -65,12 +67,12 @@ realign/dashboard/widgets/terminal_panel.py,sha256=uoi3LjgYWyFE6Yr208KC5iKg0QxLc
|
|
|
65
67
|
realign/dashboard/widgets/watcher_panel.py,sha256=O_mdDacgc87xA-5KEfta53Ik_Xsk_B2OfwenMOTtGw8,19722
|
|
66
68
|
realign/dashboard/widgets/worker_panel.py,sha256=F_jKWABuCNmjQgeeuCr4KnFRKdY4CLTNcEXMYwsNaSk,18691
|
|
67
69
|
realign/db/__init__.py,sha256=65LsNdsq_rkwNC1eg1OAr3HC0ORXtelOh0I8MhNGr-g,3288
|
|
68
|
-
realign/db/base.py,sha256=
|
|
70
|
+
realign/db/base.py,sha256=5baEwoGR5X2SyQXkXuyeUP2zelcuQardVouD2S-qils,13703
|
|
69
71
|
realign/db/locks.py,sha256=yzCiPJZ4eOQX-Q4mXB6s76U2U7lXAzIBBy1t59w-AVU,1698
|
|
70
72
|
realign/db/migrate_agents.py,sha256=cDeVUzKW950dJ0lV74QObHuONqKwErSrXI5akU2vBmQ,9633
|
|
71
73
|
realign/db/migration.py,sha256=af1QFEfIh_qX0pFyXzm5gWFVbQn0sKOUNLSJHlr__FU,13405
|
|
72
|
-
realign/db/schema.py,sha256=
|
|
73
|
-
realign/db/sqlite_db.py,sha256=
|
|
74
|
+
realign/db/schema.py,sha256=YHj5PGZWbCl0VG0epnMF_Ofg3jRiLHq6SLHCi1q34eQ,30181
|
|
75
|
+
realign/db/sqlite_db.py,sha256=0tB_chSBr44_VTFLe5_hmQk5xUuNym9JZLFSAAE9DNk,104225
|
|
74
76
|
realign/events/__init__.py,sha256=IM-NxF4Zk2hYFD07k4WrfNRuuiC9ihGjf4GBpJhjd2E,35
|
|
75
77
|
realign/events/debouncer.py,sha256=U3Q7dYpnMsAgWsW_E_IbSC4lrdEoi6H_SFLGLOAazs4,3062
|
|
76
78
|
realign/events/event_summarizer.py,sha256=ZLiwOXWN8eawep3cQs3Wh9QLSypvU1SRbe8GTJXJQaY,8272
|
|
@@ -89,8 +91,8 @@ realign/triggers/next_turn_trigger.py,sha256=BpP0PWn4mU1MZd6mv89jWcjs8Jtv0zEWapW
|
|
|
89
91
|
realign/triggers/registry.py,sha256=cb-AVLbYB2pqwfWL3q1DQxLv4kOw7g7m-GshTdfFESc,3827
|
|
90
92
|
realign/triggers/turn_status.py,sha256=wAZEhXDAmDoX5F-ohWfSnZZ0eA6DAJ9svSPiSv_f6sg,6041
|
|
91
93
|
realign/triggers/turn_summary.py,sha256=f3hEUshgv9skJ9AbfWpoYs417lsv_HK2A_vpPjgryO4,4467
|
|
92
|
-
aline_ai-0.5.
|
|
93
|
-
aline_ai-0.5.
|
|
94
|
-
aline_ai-0.5.
|
|
95
|
-
aline_ai-0.5.
|
|
96
|
-
aline_ai-0.5.
|
|
94
|
+
aline_ai-0.5.13.dist-info/METADATA,sha256=yh0oIdfIbiWQHUhP8UPOdsLFxqCjsdvF3xYfAWquT9c,1598
|
|
95
|
+
aline_ai-0.5.13.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
96
|
+
aline_ai-0.5.13.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
|
|
97
|
+
aline_ai-0.5.13.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
|
|
98
|
+
aline_ai-0.5.13.dist-info/RECORD,,
|
realign/__init__.py
CHANGED
realign/auth.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Authentication module for Aline CLI.
|
|
4
|
+
|
|
5
|
+
Handles Supabase authentication via web login flow:
|
|
6
|
+
1. User runs `aline login`
|
|
7
|
+
2. CLI starts a local HTTP server and opens browser to web login page
|
|
8
|
+
3. User logs in on web
|
|
9
|
+
4. Web redirects to local server with CLI token
|
|
10
|
+
5. CLI validates token and stores credentials automatically
|
|
11
|
+
|
|
12
|
+
Credentials are stored in ~/.aline/auth.yaml with 0600 permissions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import socket
|
|
18
|
+
import threading
|
|
19
|
+
import webbrowser
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional, Tuple
|
|
25
|
+
from urllib.parse import urlparse, parse_qs
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import httpx
|
|
29
|
+
HTTPX_AVAILABLE = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
HTTPX_AVAILABLE = False
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import yaml
|
|
35
|
+
YAML_AVAILABLE = True
|
|
36
|
+
except ImportError:
|
|
37
|
+
YAML_AVAILABLE = False
|
|
38
|
+
|
|
39
|
+
from .logging_config import setup_logger
|
|
40
|
+
from .config import ReAlignConfig
|
|
41
|
+
|
|
42
|
+
logger = setup_logger("realign.auth", "auth.log")
|
|
43
|
+
|
|
44
|
+
# Hardcoded Supabase/backend configuration
|
|
45
|
+
DEFAULT_AUTH_URL = "https://realign-server.vercel.app"
|
|
46
|
+
CLI_LOGIN_PATH = "/cli-login"
|
|
47
|
+
CLI_VALIDATE_PATH = "/api/auth/cli/validate"
|
|
48
|
+
CLI_REFRESH_PATH = "/api/auth/cli/refresh"
|
|
49
|
+
|
|
50
|
+
# Token refresh buffer (refresh 5 minutes before expiry)
|
|
51
|
+
REFRESH_BUFFER_SECONDS = 300
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AuthCredentials:
|
|
56
|
+
"""Stored authentication credentials."""
|
|
57
|
+
access_token: str
|
|
58
|
+
refresh_token: str
|
|
59
|
+
expires_at: datetime
|
|
60
|
+
user_id: str
|
|
61
|
+
email: str
|
|
62
|
+
provider: str # email, github, google
|
|
63
|
+
|
|
64
|
+
def is_expired(self) -> bool:
|
|
65
|
+
"""Check if access token is expired or will expire soon."""
|
|
66
|
+
now = datetime.now(timezone.utc)
|
|
67
|
+
buffer = REFRESH_BUFFER_SECONDS
|
|
68
|
+
return (self.expires_at - now).total_seconds() < buffer
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict:
|
|
71
|
+
"""Convert to dictionary for YAML storage."""
|
|
72
|
+
return {
|
|
73
|
+
"access_token": self.access_token,
|
|
74
|
+
"refresh_token": self.refresh_token,
|
|
75
|
+
"expires_at": self.expires_at.isoformat(),
|
|
76
|
+
"user_id": self.user_id,
|
|
77
|
+
"email": self.email,
|
|
78
|
+
"provider": self.provider,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_dict(cls, data: dict) -> "AuthCredentials":
|
|
83
|
+
"""Create from dictionary (YAML load)."""
|
|
84
|
+
expires_at = data.get("expires_at", "")
|
|
85
|
+
if isinstance(expires_at, str):
|
|
86
|
+
# Parse ISO format datetime
|
|
87
|
+
expires_at = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
88
|
+
elif isinstance(expires_at, datetime):
|
|
89
|
+
if expires_at.tzinfo is None:
|
|
90
|
+
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
91
|
+
|
|
92
|
+
return cls(
|
|
93
|
+
access_token=data.get("access_token", ""),
|
|
94
|
+
refresh_token=data.get("refresh_token", ""),
|
|
95
|
+
expires_at=expires_at,
|
|
96
|
+
user_id=data.get("user_id", ""),
|
|
97
|
+
email=data.get("email", ""),
|
|
98
|
+
provider=data.get("provider", ""),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_auth_file_path() -> Path:
|
|
103
|
+
"""Get path to auth credentials file."""
|
|
104
|
+
return Path.home() / ".aline" / "auth.yaml"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_credentials() -> Optional[AuthCredentials]:
|
|
108
|
+
"""
|
|
109
|
+
Load stored credentials from ~/.aline/auth.yaml.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
AuthCredentials if found and valid, None otherwise
|
|
113
|
+
"""
|
|
114
|
+
if not YAML_AVAILABLE:
|
|
115
|
+
logger.warning("PyYAML not available, cannot load credentials")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
auth_file = get_auth_file_path()
|
|
119
|
+
if not auth_file.exists():
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
with open(auth_file, "r") as f:
|
|
124
|
+
data = yaml.safe_load(f)
|
|
125
|
+
|
|
126
|
+
if not data:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
return AuthCredentials.from_dict(data)
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Failed to load credentials: {e}")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_credentials(credentials: AuthCredentials) -> bool:
|
|
137
|
+
"""
|
|
138
|
+
Save credentials to ~/.aline/auth.yaml with secure permissions.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
credentials: AuthCredentials to save
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if saved successfully, False otherwise
|
|
145
|
+
"""
|
|
146
|
+
if not YAML_AVAILABLE:
|
|
147
|
+
logger.error("PyYAML not available, cannot save credentials")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
auth_file = get_auth_file_path()
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
# Ensure directory exists
|
|
154
|
+
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
|
|
156
|
+
# Write credentials
|
|
157
|
+
with open(auth_file, "w") as f:
|
|
158
|
+
yaml.dump(credentials.to_dict(), f, default_flow_style=False)
|
|
159
|
+
|
|
160
|
+
# Set secure permissions (owner read/write only)
|
|
161
|
+
os.chmod(auth_file, 0o600)
|
|
162
|
+
|
|
163
|
+
logger.info(f"Saved credentials for {credentials.email}")
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f"Failed to save credentials: {e}")
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clear_credentials() -> bool:
|
|
172
|
+
"""
|
|
173
|
+
Clear stored credentials (logout).
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if cleared successfully, False otherwise
|
|
177
|
+
"""
|
|
178
|
+
auth_file = get_auth_file_path()
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
if auth_file.exists():
|
|
182
|
+
auth_file.unlink()
|
|
183
|
+
logger.info("Cleared credentials")
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"Failed to clear credentials: {e}")
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_logged_in() -> bool:
|
|
192
|
+
"""
|
|
193
|
+
Check if user is logged in with valid credentials.
|
|
194
|
+
|
|
195
|
+
This checks both:
|
|
196
|
+
1. Credentials exist
|
|
197
|
+
2. Either access_token is valid OR refresh_token can refresh it
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if logged in, False otherwise
|
|
201
|
+
"""
|
|
202
|
+
credentials = load_credentials()
|
|
203
|
+
if not credentials:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
# If access token is not expired, we're good
|
|
207
|
+
if not credentials.is_expired():
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
# If expired, check if we can refresh
|
|
211
|
+
if credentials.refresh_token:
|
|
212
|
+
return True # Can attempt refresh
|
|
213
|
+
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_current_user() -> Optional[AuthCredentials]:
|
|
218
|
+
"""
|
|
219
|
+
Get current user credentials, refreshing if needed.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
AuthCredentials if logged in (with refreshed token if needed), None otherwise
|
|
223
|
+
"""
|
|
224
|
+
credentials = load_credentials()
|
|
225
|
+
if not credentials:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
# If access token is expired, try to refresh
|
|
229
|
+
if credentials.is_expired():
|
|
230
|
+
refreshed = refresh_token(credentials)
|
|
231
|
+
if refreshed:
|
|
232
|
+
return refreshed
|
|
233
|
+
else:
|
|
234
|
+
# Refresh failed, credentials invalid
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
return credentials
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_access_token() -> Optional[str]:
|
|
241
|
+
"""
|
|
242
|
+
Get current access token, refreshing if needed.
|
|
243
|
+
|
|
244
|
+
This is the main function to call before making authenticated requests.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Access token string if logged in, None otherwise
|
|
248
|
+
"""
|
|
249
|
+
credentials = get_current_user()
|
|
250
|
+
if credentials:
|
|
251
|
+
return credentials.access_token
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def refresh_token(credentials: AuthCredentials) -> Optional[AuthCredentials]:
|
|
256
|
+
"""
|
|
257
|
+
Refresh access token using refresh token.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
credentials: Current credentials with refresh_token
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
New AuthCredentials if refresh succeeded, None otherwise
|
|
264
|
+
"""
|
|
265
|
+
if not HTTPX_AVAILABLE:
|
|
266
|
+
logger.error("httpx not available for token refresh")
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
if not credentials.refresh_token:
|
|
270
|
+
logger.warning("No refresh token available")
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
config = ReAlignConfig.load()
|
|
274
|
+
backend_url = config.share_backend_url or DEFAULT_AUTH_URL
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
response = httpx.post(
|
|
278
|
+
f"{backend_url}{CLI_REFRESH_PATH}",
|
|
279
|
+
json={"refresh_token": credentials.refresh_token},
|
|
280
|
+
timeout=30.0,
|
|
281
|
+
)
|
|
282
|
+
response.raise_for_status()
|
|
283
|
+
data = response.json()
|
|
284
|
+
|
|
285
|
+
if not data.get("success"):
|
|
286
|
+
logger.error(f"Token refresh failed: {data.get('error')}")
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
# Create new credentials with refreshed tokens
|
|
290
|
+
new_credentials = AuthCredentials(
|
|
291
|
+
access_token=data["access_token"],
|
|
292
|
+
refresh_token=data.get("refresh_token", credentials.refresh_token),
|
|
293
|
+
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")),
|
|
294
|
+
user_id=credentials.user_id,
|
|
295
|
+
email=credentials.email,
|
|
296
|
+
provider=credentials.provider,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Save refreshed credentials
|
|
300
|
+
save_credentials(new_credentials)
|
|
301
|
+
logger.info("Token refreshed successfully")
|
|
302
|
+
|
|
303
|
+
return new_credentials
|
|
304
|
+
|
|
305
|
+
except Exception as e:
|
|
306
|
+
logger.error(f"Token refresh failed: {e}")
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def validate_cli_token(cli_token: str) -> Optional[AuthCredentials]:
|
|
311
|
+
"""
|
|
312
|
+
Validate a one-time CLI token and exchange for credentials.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
cli_token: One-time token from web login page
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
AuthCredentials if valid, None otherwise
|
|
319
|
+
"""
|
|
320
|
+
if not HTTPX_AVAILABLE:
|
|
321
|
+
logger.error("httpx not available for token validation")
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
config = ReAlignConfig.load()
|
|
325
|
+
backend_url = config.share_backend_url or DEFAULT_AUTH_URL
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
response = httpx.post(
|
|
329
|
+
f"{backend_url}{CLI_VALIDATE_PATH}",
|
|
330
|
+
json={"cli_token": cli_token},
|
|
331
|
+
timeout=30.0,
|
|
332
|
+
)
|
|
333
|
+
response.raise_for_status()
|
|
334
|
+
data = response.json()
|
|
335
|
+
|
|
336
|
+
if not data.get("success"):
|
|
337
|
+
logger.error(f"Token validation failed: {data.get('error')}")
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
# Create credentials from response
|
|
341
|
+
credentials = AuthCredentials(
|
|
342
|
+
access_token=data["access_token"],
|
|
343
|
+
refresh_token=data["refresh_token"],
|
|
344
|
+
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")),
|
|
345
|
+
user_id=data["user_id"],
|
|
346
|
+
email=data["email"],
|
|
347
|
+
provider=data.get("provider", "unknown"),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
logger.info(f"Token validated for user: {credentials.email}")
|
|
351
|
+
return credentials
|
|
352
|
+
|
|
353
|
+
except httpx.HTTPStatusError as e:
|
|
354
|
+
if e.response.status_code == 400:
|
|
355
|
+
try:
|
|
356
|
+
error_data = e.response.json()
|
|
357
|
+
logger.error(f"Token validation failed: {error_data.get('error')}")
|
|
358
|
+
except Exception:
|
|
359
|
+
logger.error(f"Token validation failed: {e}")
|
|
360
|
+
else:
|
|
361
|
+
logger.error(f"Token validation failed: {e}")
|
|
362
|
+
return None
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.error(f"Token validation failed: {e}")
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def open_login_page(callback_port: Optional[int] = None) -> str:
|
|
369
|
+
"""
|
|
370
|
+
Open the web login page in browser.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
callback_port: If provided, include callback URL for automatic token receipt
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
The URL that was opened
|
|
377
|
+
"""
|
|
378
|
+
config = ReAlignConfig.load()
|
|
379
|
+
backend_url = config.share_backend_url or DEFAULT_AUTH_URL
|
|
380
|
+
|
|
381
|
+
if callback_port:
|
|
382
|
+
# Include callback URL for automatic flow
|
|
383
|
+
callback_url = f"http://localhost:{callback_port}/callback"
|
|
384
|
+
login_url = f"{backend_url}{CLI_LOGIN_PATH}?callback={callback_url}"
|
|
385
|
+
else:
|
|
386
|
+
login_url = f"{backend_url}{CLI_LOGIN_PATH}"
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
webbrowser.open(login_url)
|
|
390
|
+
logger.info(f"Opened login page: {login_url}")
|
|
391
|
+
except Exception as e:
|
|
392
|
+
logger.warning(f"Failed to open browser: {e}")
|
|
393
|
+
|
|
394
|
+
return login_url
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# Local callback server for automatic token receipt
|
|
398
|
+
_received_token: Optional[str] = None
|
|
399
|
+
_server_error: Optional[str] = None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
403
|
+
"""HTTP handler for receiving CLI token callback."""
|
|
404
|
+
|
|
405
|
+
def log_message(self, format, *args):
|
|
406
|
+
"""Suppress default logging."""
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
def do_GET(self):
|
|
410
|
+
global _received_token, _server_error
|
|
411
|
+
|
|
412
|
+
parsed = urlparse(self.path)
|
|
413
|
+
|
|
414
|
+
if parsed.path == "/callback":
|
|
415
|
+
params = parse_qs(parsed.query)
|
|
416
|
+
|
|
417
|
+
if "token" in params:
|
|
418
|
+
_received_token = params["token"][0]
|
|
419
|
+
self._send_success_page()
|
|
420
|
+
elif "error" in params:
|
|
421
|
+
_server_error = params.get("error", ["Unknown error"])[0]
|
|
422
|
+
self._send_error_page(_server_error)
|
|
423
|
+
else:
|
|
424
|
+
_server_error = "No token received"
|
|
425
|
+
self._send_error_page(_server_error)
|
|
426
|
+
else:
|
|
427
|
+
self.send_response(404)
|
|
428
|
+
self.end_headers()
|
|
429
|
+
|
|
430
|
+
def _send_success_page(self):
|
|
431
|
+
"""Send success HTML page."""
|
|
432
|
+
self.send_response(200)
|
|
433
|
+
self.send_header("Content-type", "text/html")
|
|
434
|
+
self.end_headers()
|
|
435
|
+
html = """
|
|
436
|
+
<!DOCTYPE html>
|
|
437
|
+
<html>
|
|
438
|
+
<head>
|
|
439
|
+
<title>Aline CLI Login</title>
|
|
440
|
+
<style>
|
|
441
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
442
|
+
display: flex; justify-content: center; align-items: center;
|
|
443
|
+
min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
444
|
+
.container { text-align: center; padding: 40px; background: white;
|
|
445
|
+
border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
446
|
+
.success { color: #22c55e; font-size: 48px; margin-bottom: 20px; }
|
|
447
|
+
h1 { color: #333; margin-bottom: 10px; }
|
|
448
|
+
p { color: #666; }
|
|
449
|
+
</style>
|
|
450
|
+
</head>
|
|
451
|
+
<body>
|
|
452
|
+
<div class="container">
|
|
453
|
+
<div class="success">✓</div>
|
|
454
|
+
<h1>Login Successful!</h1>
|
|
455
|
+
<p>You can close this window and return to the terminal.</p>
|
|
456
|
+
</div>
|
|
457
|
+
</body>
|
|
458
|
+
</html>
|
|
459
|
+
"""
|
|
460
|
+
self.wfile.write(html.encode())
|
|
461
|
+
|
|
462
|
+
def _send_error_page(self, error: str):
|
|
463
|
+
"""Send error HTML page."""
|
|
464
|
+
self.send_response(200)
|
|
465
|
+
self.send_header("Content-type", "text/html")
|
|
466
|
+
self.end_headers()
|
|
467
|
+
html = f"""
|
|
468
|
+
<!DOCTYPE html>
|
|
469
|
+
<html>
|
|
470
|
+
<head>
|
|
471
|
+
<title>Aline CLI Login - Error</title>
|
|
472
|
+
<style>
|
|
473
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
474
|
+
display: flex; justify-content: center; align-items: center;
|
|
475
|
+
min-height: 100vh; margin: 0; background: #f5f5f5; }}
|
|
476
|
+
.container {{ text-align: center; padding: 40px; background: white;
|
|
477
|
+
border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
|
|
478
|
+
.error {{ color: #ef4444; font-size: 48px; margin-bottom: 20px; }}
|
|
479
|
+
h1 {{ color: #333; margin-bottom: 10px; }}
|
|
480
|
+
p {{ color: #666; }}
|
|
481
|
+
</style>
|
|
482
|
+
</head>
|
|
483
|
+
<body>
|
|
484
|
+
<div class="container">
|
|
485
|
+
<div class="error">✗</div>
|
|
486
|
+
<h1>Login Failed</h1>
|
|
487
|
+
<p>{error}</p>
|
|
488
|
+
</div>
|
|
489
|
+
</body>
|
|
490
|
+
</html>
|
|
491
|
+
"""
|
|
492
|
+
self.wfile.write(html.encode())
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def find_free_port() -> int:
|
|
496
|
+
"""Find a free port for the local callback server."""
|
|
497
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
498
|
+
s.bind(('', 0))
|
|
499
|
+
s.listen(1)
|
|
500
|
+
port = s.getsockname()[1]
|
|
501
|
+
return port
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def start_callback_server(port: int, timeout: int = 300) -> Tuple[Optional[str], Optional[str]]:
|
|
505
|
+
"""
|
|
506
|
+
Start a local HTTP server to receive the CLI token callback.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
port: Port to listen on
|
|
510
|
+
timeout: Maximum time to wait for callback (seconds)
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Tuple of (token, error) - one will be None
|
|
514
|
+
"""
|
|
515
|
+
global _received_token, _server_error
|
|
516
|
+
_received_token = None
|
|
517
|
+
_server_error = None
|
|
518
|
+
|
|
519
|
+
server = HTTPServer(('localhost', port), CallbackHandler)
|
|
520
|
+
server.timeout = timeout
|
|
521
|
+
|
|
522
|
+
# Handle one request (the callback)
|
|
523
|
+
server.handle_request()
|
|
524
|
+
server.server_close()
|
|
525
|
+
|
|
526
|
+
return _received_token, _server_error
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def get_auth_headers() -> dict:
|
|
530
|
+
"""
|
|
531
|
+
Get HTTP headers for authenticated requests.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Dict with Authorization header if logged in, empty dict otherwise
|
|
535
|
+
"""
|
|
536
|
+
token = get_access_token()
|
|
537
|
+
if token:
|
|
538
|
+
return {"Authorization": f"Bearer {token}"}
|
|
539
|
+
return {}
|
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",
|
|
@@ -57,6 +57,28 @@ app.command(name="config")(config.config_command)
|
|
|
57
57
|
app.command(name="upgrade")(upgrade.upgrade_command)
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
# Auth commands
|
|
61
|
+
@app.command(name="login")
|
|
62
|
+
def login_cli():
|
|
63
|
+
"""Login to Aline via web browser to enable share features."""
|
|
64
|
+
exit_code = auth.login_command()
|
|
65
|
+
raise typer.Exit(code=exit_code)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command(name="logout")
|
|
69
|
+
def logout_cli():
|
|
70
|
+
"""Logout from Aline and clear local credentials."""
|
|
71
|
+
exit_code = auth.logout_command()
|
|
72
|
+
raise typer.Exit(code=exit_code)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command(name="whoami")
|
|
76
|
+
def whoami_cli():
|
|
77
|
+
"""Display current login status and user information."""
|
|
78
|
+
exit_code = auth.whoami_command()
|
|
79
|
+
raise typer.Exit(code=exit_code)
|
|
80
|
+
|
|
81
|
+
|
|
60
82
|
@app.command(name="search")
|
|
61
83
|
def search_cli(
|
|
62
84
|
query: str = typer.Argument(..., help="Search query (keywords or regex pattern)"),
|