aline-ai 0.5.11__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.5.11
3
+ Version: 0.5.13
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,9 +1,10 @@
1
- aline_ai-0.5.11.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=J7JxBCOqQGzUmBxeTzF9YbnIPjgauOOl8ukALr-3pCM,1624
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=9VS3WbysZ78NRK5EvkJVg8s6Uh2TQjsGX1E9Pl81pHc,31234
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=lIKZqeOwYc_gHo760lYYX6PnapuKrCWGqT5SA8-PbeA,12044
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=zM6ABnc9WoyPQ7GxiciMTwRyOeC6k5_XGTcI4eqx7TI,106961
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=Djy1aO7MoU1_ewzn6CZ43oNhSEEonV3sTkSQbHgiaKI,135806
39
- realign/commands/import_shares.py,sha256=ukX8huvLvEM5g0qEIoqrV1-imz1g-r0Jj2FqD-ojrIA,25297
40
- realign/commands/init.py,sha256=ef-q3Qz5D_0Eqld8qjtX26X2QrovBSYcva3uAjiJuwk,33015
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=fWL3kaRkqE03-NtFLaXlx93hJAQrAuNPSoYhOyQZfq8,136273
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=Afezfd6nvHo0Q44IS2UZTPJsYmHbqzjx7bi5jWrCDPA,11182
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=MIqu08uG8i5atjZ9uF-uc0Rx35ondxCtUPK92hMoHx4,13179
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=93dfMtw3LgkBMpiUlCQ0EscY9RFsuS8sEBDckH8lGws,25864
73
- realign/db/sqlite_db.py,sha256=sZXcvEaSu4C_MQ8pF20RUhwsPtBlNr6ANqf8suM5X8E,102660
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.11.dist-info/METADATA,sha256=fq0cvqmYD9PExKiqsufTsOYkXU0Y90QcV4P0AyTQBMw,1598
93
- aline_ai-0.5.11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
94
- aline_ai-0.5.11.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
95
- aline_ai-0.5.11.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
96
- aline_ai-0.5.11.dist-info/RECORD,,
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
@@ -3,7 +3,7 @@
3
3
  import hashlib
4
4
  from pathlib import Path
5
5
 
6
- __version__ = "0.5.11"
6
+ __version__ = "0.5.13"
7
7
 
8
8
 
9
9
  def get_realign_dir(project_root: Path) -> Path:
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">&#10003;</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">&#10007;</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)"),