morphy-cli 0.5.0__tar.gz
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.
- morphy_cli-0.5.0/PKG-INFO +136 -0
- morphy_cli-0.5.0/README.md +113 -0
- morphy_cli-0.5.0/morphy_cli/__init__.py +1 -0
- morphy_cli-0.5.0/morphy_cli/__main__.py +3 -0
- morphy_cli-0.5.0/morphy_cli/auth.py +321 -0
- morphy_cli-0.5.0/morphy_cli/client.py +192 -0
- morphy_cli-0.5.0/morphy_cli/completer.py +65 -0
- morphy_cli-0.5.0/morphy_cli/control.py +323 -0
- morphy_cli-0.5.0/morphy_cli/formatting.py +143 -0
- morphy_cli-0.5.0/morphy_cli/main.py +113 -0
- morphy_cli-0.5.0/morphy_cli/queries.py +75 -0
- morphy_cli-0.5.0/morphy_cli/shell.py +747 -0
- morphy_cli-0.5.0/morphy_cli.egg-info/PKG-INFO +136 -0
- morphy_cli-0.5.0/morphy_cli.egg-info/SOURCES.txt +19 -0
- morphy_cli-0.5.0/morphy_cli.egg-info/dependency_links.txt +1 -0
- morphy_cli-0.5.0/morphy_cli.egg-info/entry_points.txt +3 -0
- morphy_cli-0.5.0/morphy_cli.egg-info/requires.txt +3 -0
- morphy_cli-0.5.0/morphy_cli.egg-info/top_level.txt +1 -0
- morphy_cli-0.5.0/pyproject.toml +34 -0
- morphy_cli-0.5.0/setup.cfg +4 -0
- morphy_cli-0.5.0/tests/test_shell.py +710 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: morphy-cli
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: MORPHY — governed natural-language database terminal
|
|
5
|
+
Author-email: Ananya Kalia <ananyakalia2704@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://morphy-web.vercel.app
|
|
7
|
+
Project-URL: Repository, https://github.com/ananyakalia14/morphy
|
|
8
|
+
Project-URL: Issues, https://github.com/ananyakalia14/morphy/issues
|
|
9
|
+
Keywords: sql,cli,database,query,governed-sql,terminal,ai
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: httpx<1,>=0.27
|
|
21
|
+
Requires-Dist: prompt-toolkit<4,>=3.0
|
|
22
|
+
Requires-Dist: keyring>=24.0.0
|
|
23
|
+
|
|
24
|
+
# MORPHY CLI — Antigravity handoff
|
|
25
|
+
|
|
26
|
+
Antigravity owns the terminal interface, commands and client experience in this folder. The shared engine lives in backend/. Read ../docs/API.md and ../AGENTS.md first.
|
|
27
|
+
|
|
28
|
+
## Existing implementation
|
|
29
|
+
|
|
30
|
+
The Python package exposes morphy and sq console commands. Install from the repository root with python -m pip install -e ./cli.
|
|
31
|
+
|
|
32
|
+
morphy starts the sample REPL. Existing commands are /help, /schema, /examples, /ask <question>, /sql <SELECT> and /exit or /quit. The sample /ask uses curated example titles.
|
|
33
|
+
|
|
34
|
+
morphy --database <UUID> selects a real organization database and reads MORPHY_ACCESS_TOKEN from the environment. --url selects the engine. --sql runs one query and exits. Live /ask previews SQL and requests confirmation before execution.
|
|
35
|
+
|
|
36
|
+
The current CLI uses httpx and a simple input loop. Access tokens remain in the process environment. It has no persisted login session, token refresh, organization chooser, connection wizard or command completion yet.
|
|
37
|
+
|
|
38
|
+
## Build on this foundation
|
|
39
|
+
|
|
40
|
+
- [x] Add prompt-toolkit for slash completion, history, multiline SQL and interruption handling.
|
|
41
|
+
- [x] Add login/logout/status using the same Supabase Auth project as the website.
|
|
42
|
+
- [x] Store persisted refresh tokens in the OS credential store and support session expiry/refresh.
|
|
43
|
+
- [x] Add organization and database selection, keeping current context visible.
|
|
44
|
+
- [x] Add /connect and CSV import flows using the engine's authenticated endpoints.
|
|
45
|
+
- [x] Add profile, invitation and member-management commands through the shared Supabase RPCs.
|
|
46
|
+
- [x] Add readable tables, JSON output, CSV export, null handling and bounded output.
|
|
47
|
+
- [x] Show generation stages, correction attempts and cache status without invented percentages.
|
|
48
|
+
- [x] Add command cancellation, network-timeout messages and graceful Ctrl+C.
|
|
49
|
+
- [x] Add noninteractive flags and stable exit codes for scripts.
|
|
50
|
+
- [x] Add command tests and integration tests against a disposable development control plane.
|
|
51
|
+
- [x] Document Windows, macOS and Linux installation. Validate both console aliases.
|
|
52
|
+
|
|
53
|
+
## Features and Commands
|
|
54
|
+
|
|
55
|
+
### Session & Identity
|
|
56
|
+
- `/login <email>`: Authenticate via Supabase GoTrue with secure password prompt.
|
|
57
|
+
- `/logout`: Clear active session, OS keyring credentials, and stored query results.
|
|
58
|
+
- `/status`: Inspect active authentication, engine URL, active organization, and target database.
|
|
59
|
+
- `/profile`: View your profile; `/profile claim <handle> <name>` or `/profile edit <name>` to edit display name.
|
|
60
|
+
- `/dashboard`: Comprehensive terminal dashboard view; `/dashboard [render <id>]` to render a saved dashboard.
|
|
61
|
+
- `/dashboards`: List saved dashboards in the active database.
|
|
62
|
+
- `/dashboard-create <title> [shared]`: Create a saved dashboard definition.
|
|
63
|
+
- `/dashboard-add <dash_id> <query_id>`: Add a saved query panel to a dashboard.
|
|
64
|
+
- `/permissions`: Inspect effective permissions and row filtering rules on active database.
|
|
65
|
+
- `/perm-revoke <role|user>`: Revoke permissions on active database.
|
|
66
|
+
|
|
67
|
+
### Organizations & Databases
|
|
68
|
+
- `/orgs`: List member organizations.
|
|
69
|
+
- `/use-org <id|name>`: Switch active organization context.
|
|
70
|
+
- `/dbs`: List databases registered in active organization.
|
|
71
|
+
- `/use-db <id|name|sample>`: Switch target database context (or return to sample workspace).
|
|
72
|
+
- `/connect <uri>`: Save and verify target PostgreSQL connection (admin).
|
|
73
|
+
- `/import <path>`: Ingest UTF-8 CSV data up to 1 MB into SQLite target database (admin).
|
|
74
|
+
|
|
75
|
+
### Team & Collaboration
|
|
76
|
+
- `/members`: List members and assigned roles in the active organization.
|
|
77
|
+
- `/invites`: View pending invitations.
|
|
78
|
+
- `/invite <username>`: Search users and dispatch organization invitations.
|
|
79
|
+
- `/accept <id>`: Accept an invitation to join an organization.
|
|
80
|
+
- `/withdraw <id>`: Withdraw/cancel an existing invitation.
|
|
81
|
+
|
|
82
|
+
### Queries & Saved Queries
|
|
83
|
+
- `/schema`: View allowed tables and column types.
|
|
84
|
+
- `/examples`: List curated natural-language query examples.
|
|
85
|
+
- `/ask <question>`: Natural language query generation with pre-execution review.
|
|
86
|
+
- `/sql <SELECT ...>`: Execute read-only SQL queries.
|
|
87
|
+
- `/save-query <name> [sql]`: Save current or specified query.
|
|
88
|
+
- `/queries`: List all saved queries.
|
|
89
|
+
- `/run-query <name>`: Execute a saved query.
|
|
90
|
+
- `/delete-query <name>`: Delete a saved query.
|
|
91
|
+
- `/export <path.csv>`: Export last query results as RFC-4180 CSV.
|
|
92
|
+
- `/json`: Print last query results as formatted JSON.
|
|
93
|
+
|
|
94
|
+
### Scripting & Automation
|
|
95
|
+
Run one-shot queries without entering interactive mode:
|
|
96
|
+
```bash
|
|
97
|
+
morphy --sql "SELECT * FROM orders LIMIT 5"
|
|
98
|
+
morphy --sql "SELECT * FROM orders LIMIT 5" --json
|
|
99
|
+
morphy --sql "SELECT * FROM orders LIMIT 5" --csv
|
|
100
|
+
```
|
|
101
|
+
Stable exit codes:
|
|
102
|
+
- `0`: Success
|
|
103
|
+
- `1`: Query, validation, or policy error
|
|
104
|
+
- `2`: Network or engine connectivity error
|
|
105
|
+
- `130`: Interrupted / cancelled via SIGINT (Ctrl+C)
|
|
106
|
+
|
|
107
|
+
## Installation & Setup
|
|
108
|
+
|
|
109
|
+
### Requirements
|
|
110
|
+
- Python >= 3.11
|
|
111
|
+
|
|
112
|
+
### Installation
|
|
113
|
+
From the repository root:
|
|
114
|
+
```bash
|
|
115
|
+
python -m pip install -e ./cli
|
|
116
|
+
```
|
|
117
|
+
Both `morphy` and `sq` console commands are installed.
|
|
118
|
+
|
|
119
|
+
On Windows, macOS and Linux:
|
|
120
|
+
```bash
|
|
121
|
+
morphy --help
|
|
122
|
+
sq --help
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Required boundaries
|
|
126
|
+
|
|
127
|
+
Keep SQL generation, target connections, encryption keys, policy enforcement and execution in the backend. The CLI sends the current user's bearer token to the configured engine. Verify HTTPS for remote authenticated endpoints.
|
|
128
|
+
|
|
129
|
+
Use the same API request/response shapes as the web app. Never ask an LLM to decide whether a query is authorized. Never execute model output directly against a target database.
|
|
130
|
+
|
|
131
|
+
Do not commit tokens, passwords or shell history. Source authentication settings from documented configuration. Support existing --url, --database and --sql callers as the interface evolves.
|
|
132
|
+
|
|
133
|
+
## First PR
|
|
134
|
+
|
|
135
|
+
Create cli/interactive-shell from main. Add the terminal framework, /help and completion around the existing HTTP client first. Include --help and interruption checks. Submit a PR with usage examples and testing evidence.
|
|
136
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# MORPHY CLI — Antigravity handoff
|
|
2
|
+
|
|
3
|
+
Antigravity owns the terminal interface, commands and client experience in this folder. The shared engine lives in backend/. Read ../docs/API.md and ../AGENTS.md first.
|
|
4
|
+
|
|
5
|
+
## Existing implementation
|
|
6
|
+
|
|
7
|
+
The Python package exposes morphy and sq console commands. Install from the repository root with python -m pip install -e ./cli.
|
|
8
|
+
|
|
9
|
+
morphy starts the sample REPL. Existing commands are /help, /schema, /examples, /ask <question>, /sql <SELECT> and /exit or /quit. The sample /ask uses curated example titles.
|
|
10
|
+
|
|
11
|
+
morphy --database <UUID> selects a real organization database and reads MORPHY_ACCESS_TOKEN from the environment. --url selects the engine. --sql runs one query and exits. Live /ask previews SQL and requests confirmation before execution.
|
|
12
|
+
|
|
13
|
+
The current CLI uses httpx and a simple input loop. Access tokens remain in the process environment. It has no persisted login session, token refresh, organization chooser, connection wizard or command completion yet.
|
|
14
|
+
|
|
15
|
+
## Build on this foundation
|
|
16
|
+
|
|
17
|
+
- [x] Add prompt-toolkit for slash completion, history, multiline SQL and interruption handling.
|
|
18
|
+
- [x] Add login/logout/status using the same Supabase Auth project as the website.
|
|
19
|
+
- [x] Store persisted refresh tokens in the OS credential store and support session expiry/refresh.
|
|
20
|
+
- [x] Add organization and database selection, keeping current context visible.
|
|
21
|
+
- [x] Add /connect and CSV import flows using the engine's authenticated endpoints.
|
|
22
|
+
- [x] Add profile, invitation and member-management commands through the shared Supabase RPCs.
|
|
23
|
+
- [x] Add readable tables, JSON output, CSV export, null handling and bounded output.
|
|
24
|
+
- [x] Show generation stages, correction attempts and cache status without invented percentages.
|
|
25
|
+
- [x] Add command cancellation, network-timeout messages and graceful Ctrl+C.
|
|
26
|
+
- [x] Add noninteractive flags and stable exit codes for scripts.
|
|
27
|
+
- [x] Add command tests and integration tests against a disposable development control plane.
|
|
28
|
+
- [x] Document Windows, macOS and Linux installation. Validate both console aliases.
|
|
29
|
+
|
|
30
|
+
## Features and Commands
|
|
31
|
+
|
|
32
|
+
### Session & Identity
|
|
33
|
+
- `/login <email>`: Authenticate via Supabase GoTrue with secure password prompt.
|
|
34
|
+
- `/logout`: Clear active session, OS keyring credentials, and stored query results.
|
|
35
|
+
- `/status`: Inspect active authentication, engine URL, active organization, and target database.
|
|
36
|
+
- `/profile`: View your profile; `/profile claim <handle> <name>` or `/profile edit <name>` to edit display name.
|
|
37
|
+
- `/dashboard`: Comprehensive terminal dashboard view; `/dashboard [render <id>]` to render a saved dashboard.
|
|
38
|
+
- `/dashboards`: List saved dashboards in the active database.
|
|
39
|
+
- `/dashboard-create <title> [shared]`: Create a saved dashboard definition.
|
|
40
|
+
- `/dashboard-add <dash_id> <query_id>`: Add a saved query panel to a dashboard.
|
|
41
|
+
- `/permissions`: Inspect effective permissions and row filtering rules on active database.
|
|
42
|
+
- `/perm-revoke <role|user>`: Revoke permissions on active database.
|
|
43
|
+
|
|
44
|
+
### Organizations & Databases
|
|
45
|
+
- `/orgs`: List member organizations.
|
|
46
|
+
- `/use-org <id|name>`: Switch active organization context.
|
|
47
|
+
- `/dbs`: List databases registered in active organization.
|
|
48
|
+
- `/use-db <id|name|sample>`: Switch target database context (or return to sample workspace).
|
|
49
|
+
- `/connect <uri>`: Save and verify target PostgreSQL connection (admin).
|
|
50
|
+
- `/import <path>`: Ingest UTF-8 CSV data up to 1 MB into SQLite target database (admin).
|
|
51
|
+
|
|
52
|
+
### Team & Collaboration
|
|
53
|
+
- `/members`: List members and assigned roles in the active organization.
|
|
54
|
+
- `/invites`: View pending invitations.
|
|
55
|
+
- `/invite <username>`: Search users and dispatch organization invitations.
|
|
56
|
+
- `/accept <id>`: Accept an invitation to join an organization.
|
|
57
|
+
- `/withdraw <id>`: Withdraw/cancel an existing invitation.
|
|
58
|
+
|
|
59
|
+
### Queries & Saved Queries
|
|
60
|
+
- `/schema`: View allowed tables and column types.
|
|
61
|
+
- `/examples`: List curated natural-language query examples.
|
|
62
|
+
- `/ask <question>`: Natural language query generation with pre-execution review.
|
|
63
|
+
- `/sql <SELECT ...>`: Execute read-only SQL queries.
|
|
64
|
+
- `/save-query <name> [sql]`: Save current or specified query.
|
|
65
|
+
- `/queries`: List all saved queries.
|
|
66
|
+
- `/run-query <name>`: Execute a saved query.
|
|
67
|
+
- `/delete-query <name>`: Delete a saved query.
|
|
68
|
+
- `/export <path.csv>`: Export last query results as RFC-4180 CSV.
|
|
69
|
+
- `/json`: Print last query results as formatted JSON.
|
|
70
|
+
|
|
71
|
+
### Scripting & Automation
|
|
72
|
+
Run one-shot queries without entering interactive mode:
|
|
73
|
+
```bash
|
|
74
|
+
morphy --sql "SELECT * FROM orders LIMIT 5"
|
|
75
|
+
morphy --sql "SELECT * FROM orders LIMIT 5" --json
|
|
76
|
+
morphy --sql "SELECT * FROM orders LIMIT 5" --csv
|
|
77
|
+
```
|
|
78
|
+
Stable exit codes:
|
|
79
|
+
- `0`: Success
|
|
80
|
+
- `1`: Query, validation, or policy error
|
|
81
|
+
- `2`: Network or engine connectivity error
|
|
82
|
+
- `130`: Interrupted / cancelled via SIGINT (Ctrl+C)
|
|
83
|
+
|
|
84
|
+
## Installation & Setup
|
|
85
|
+
|
|
86
|
+
### Requirements
|
|
87
|
+
- Python >= 3.11
|
|
88
|
+
|
|
89
|
+
### Installation
|
|
90
|
+
From the repository root:
|
|
91
|
+
```bash
|
|
92
|
+
python -m pip install -e ./cli
|
|
93
|
+
```
|
|
94
|
+
Both `morphy` and `sq` console commands are installed.
|
|
95
|
+
|
|
96
|
+
On Windows, macOS and Linux:
|
|
97
|
+
```bash
|
|
98
|
+
morphy --help
|
|
99
|
+
sq --help
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Required boundaries
|
|
103
|
+
|
|
104
|
+
Keep SQL generation, target connections, encryption keys, policy enforcement and execution in the backend. The CLI sends the current user's bearer token to the configured engine. Verify HTTPS for remote authenticated endpoints.
|
|
105
|
+
|
|
106
|
+
Use the same API request/response shapes as the web app. Never ask an LLM to decide whether a query is authorized. Never execute model output directly against a target database.
|
|
107
|
+
|
|
108
|
+
Do not commit tokens, passwords or shell history. Source authentication settings from documented configuration. Support existing --url, --database and --sql callers as the interface evolves.
|
|
109
|
+
|
|
110
|
+
## First PR
|
|
111
|
+
|
|
112
|
+
Create cli/interactive-shell from main. Add the terminal framework, /help and completion around the existing HTTP client first. Include --help and interruption checks. Submit a PR with usage examples and testing evidence.
|
|
113
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Authentication and session management for MORPHY CLI using Supabase Auth and OS keyring."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
import httpx
|
|
9
|
+
import keyring
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path.home() / ".morphy"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
13
|
+
FALLBACK_TOKEN_FILE = CONFIG_DIR / ".tokens"
|
|
14
|
+
KEYRING_SERVICE = "morphy-cli"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(Exception):
|
|
18
|
+
"""Authentication or session error with user-actionable message."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Session:
|
|
23
|
+
"""Holds authenticated session details."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
access_token: str,
|
|
28
|
+
refresh_token: str,
|
|
29
|
+
user_id: str,
|
|
30
|
+
email: str,
|
|
31
|
+
expires_at: float,
|
|
32
|
+
):
|
|
33
|
+
self.access_token = access_token
|
|
34
|
+
self.refresh_token = refresh_token
|
|
35
|
+
self.user_id = user_id
|
|
36
|
+
self.email = email
|
|
37
|
+
self.expires_at = expires_at
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_expired(self) -> bool:
|
|
41
|
+
"""Returns True if the access token has expired or is expiring in <= 60s."""
|
|
42
|
+
return time.time() >= (self.expires_at - 60)
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"access_token": self.access_token,
|
|
47
|
+
"refresh_token": self.refresh_token,
|
|
48
|
+
"user_id": self.user_id,
|
|
49
|
+
"email": self.email,
|
|
50
|
+
"expires_at": self.expires_at,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any]) -> "Session":
|
|
55
|
+
return cls(
|
|
56
|
+
access_token=data["access_token"],
|
|
57
|
+
refresh_token=data["refresh_token"],
|
|
58
|
+
user_id=data["user_id"],
|
|
59
|
+
email=data["email"],
|
|
60
|
+
expires_at=data["expires_at"],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_config() -> tuple[str, str]:
|
|
65
|
+
"""Load Supabase URL and anon key from env vars or ~/.morphy/config.json."""
|
|
66
|
+
url = os.getenv("MORPHY_SUPABASE_URL")
|
|
67
|
+
key = os.getenv("MORPHY_SUPABASE_ANON_KEY") or os.getenv("MORPHY_SUPABASE_PUBLIC_KEY")
|
|
68
|
+
|
|
69
|
+
if not url or not key:
|
|
70
|
+
if CONFIG_FILE.is_file():
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
73
|
+
url = url or data.get("supabase_url")
|
|
74
|
+
key = key or data.get("supabase_anon_key")
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
url = (url or "http://127.0.0.1:54321").rstrip("/")
|
|
79
|
+
key = key or ""
|
|
80
|
+
return url, key
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_config(supabase_url: str, supabase_anon_key: str) -> None:
|
|
84
|
+
"""Save Supabase configuration to ~/.morphy/config.json."""
|
|
85
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
payload = {
|
|
87
|
+
"supabase_url": supabase_url.rstrip("/"),
|
|
88
|
+
"supabase_anon_key": supabase_anon_key,
|
|
89
|
+
}
|
|
90
|
+
CONFIG_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AuthManager:
|
|
94
|
+
"""Manages Supabase sessions, token refreshing, and OS credential storage."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, supabase_url: Optional[str] = None, supabase_key: Optional[str] = None):
|
|
97
|
+
cfg_url, cfg_key = load_config()
|
|
98
|
+
self.supabase_url = (supabase_url or cfg_url).rstrip("/")
|
|
99
|
+
self.supabase_key = supabase_key if supabase_key is not None else cfg_key
|
|
100
|
+
self.current_session: Optional[Session] = None
|
|
101
|
+
self._keyring_key = f"refresh:{self.supabase_url}"
|
|
102
|
+
|
|
103
|
+
def _headers(self) -> dict[str, str]:
|
|
104
|
+
headers = {"apikey": self.supabase_key}
|
|
105
|
+
if self.current_session and self.current_session.access_token:
|
|
106
|
+
headers["Authorization"] = f"Bearer {self.current_session.access_token}"
|
|
107
|
+
return headers
|
|
108
|
+
|
|
109
|
+
def _store_refresh_token(self, refresh_token: str, session: Session) -> None:
|
|
110
|
+
stored = False
|
|
111
|
+
try:
|
|
112
|
+
keyring.set_password(KEYRING_SERVICE, self._keyring_key, refresh_token)
|
|
113
|
+
stored = True
|
|
114
|
+
except Exception:
|
|
115
|
+
stored = False
|
|
116
|
+
|
|
117
|
+
# Fallback file if keyring fails (e.g. headless CI or container)
|
|
118
|
+
if not stored:
|
|
119
|
+
try:
|
|
120
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
FALLBACK_TOKEN_FILE.write_text(
|
|
122
|
+
json.dumps(session.to_dict()),
|
|
123
|
+
encoding="utf-8"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
os.chmod(FALLBACK_TOKEN_FILE, 0o600)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def _get_stored_refresh_token(self) -> Optional[str]:
|
|
133
|
+
try:
|
|
134
|
+
token = keyring.get_password(KEYRING_SERVICE, self._keyring_key)
|
|
135
|
+
if token:
|
|
136
|
+
return token
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
if FALLBACK_TOKEN_FILE.is_file():
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(FALLBACK_TOKEN_FILE.read_text(encoding="utf-8"))
|
|
143
|
+
return data.get("refresh_token")
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _clear_stored_tokens(self) -> None:
|
|
149
|
+
try:
|
|
150
|
+
keyring.delete_password(KEYRING_SERVICE, self._keyring_key)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
if FALLBACK_TOKEN_FILE.is_file():
|
|
155
|
+
try:
|
|
156
|
+
FALLBACK_TOKEN_FILE.unlink(missing_ok=True)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def login(self, email: str, password: str) -> Session:
|
|
161
|
+
"""Authenticate with Supabase GoTrue using email and password."""
|
|
162
|
+
if not self.supabase_key:
|
|
163
|
+
raise AuthError(
|
|
164
|
+
"Supabase Anon Key is not configured. Set MORPHY_SUPABASE_ANON_KEY or run config."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
endpoint = f"{self.supabase_url}/auth/v1/token?grant_type=password"
|
|
168
|
+
payload = {"email": email, "password": password}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
with httpx.Client(timeout=15.0) as client:
|
|
172
|
+
res = client.post(endpoint, json=payload, headers={"apikey": self.supabase_key})
|
|
173
|
+
except httpx.HTTPError as exc:
|
|
174
|
+
raise AuthError(f"Network error during authentication: {exc}")
|
|
175
|
+
|
|
176
|
+
if res.status_code != 200:
|
|
177
|
+
try:
|
|
178
|
+
err_data = res.json()
|
|
179
|
+
detail = err_data.get("msg") or err_data.get("error_description") or res.text
|
|
180
|
+
except Exception:
|
|
181
|
+
detail = res.text
|
|
182
|
+
raise AuthError(f"Login failed ({res.status_code}): {detail}")
|
|
183
|
+
|
|
184
|
+
data = res.json()
|
|
185
|
+
expires_in = data.get("expires_in", 3600)
|
|
186
|
+
user_info = data.get("user", {})
|
|
187
|
+
|
|
188
|
+
session = Session(
|
|
189
|
+
access_token=data["access_token"],
|
|
190
|
+
refresh_token=data["refresh_token"],
|
|
191
|
+
user_id=user_info.get("id", ""),
|
|
192
|
+
email=user_info.get("email", email),
|
|
193
|
+
expires_at=time.time() + expires_in,
|
|
194
|
+
)
|
|
195
|
+
self.current_session = session
|
|
196
|
+
self._store_refresh_token(session.refresh_token, session)
|
|
197
|
+
return session
|
|
198
|
+
|
|
199
|
+
def refresh(self) -> Session:
|
|
200
|
+
"""Refresh current session using refresh token."""
|
|
201
|
+
refresh_token = (
|
|
202
|
+
self.current_session.refresh_token
|
|
203
|
+
if self.current_session
|
|
204
|
+
else self._get_stored_refresh_token()
|
|
205
|
+
)
|
|
206
|
+
if not refresh_token:
|
|
207
|
+
raise AuthError("No active session or refresh token found. Please /login.")
|
|
208
|
+
|
|
209
|
+
endpoint = f"{self.supabase_url}/auth/v1/token?grant_type=refresh_token"
|
|
210
|
+
payload = {"refresh_token": refresh_token}
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
with httpx.Client(timeout=15.0) as client:
|
|
214
|
+
res = client.post(endpoint, json=payload, headers={"apikey": self.supabase_key})
|
|
215
|
+
except httpx.HTTPError as exc:
|
|
216
|
+
raise AuthError(f"Network error during token refresh: {exc}")
|
|
217
|
+
|
|
218
|
+
if res.status_code != 200:
|
|
219
|
+
self._clear_stored_tokens()
|
|
220
|
+
self.current_session = None
|
|
221
|
+
raise AuthError("Session expired or refresh token invalid. Please /login again.")
|
|
222
|
+
|
|
223
|
+
data = res.json()
|
|
224
|
+
expires_in = data.get("expires_in", 3600)
|
|
225
|
+
user_info = data.get("user", {})
|
|
226
|
+
|
|
227
|
+
session = Session(
|
|
228
|
+
access_token=data["access_token"],
|
|
229
|
+
refresh_token=data["refresh_token"],
|
|
230
|
+
user_id=user_info.get("id", self.current_session.user_id if self.current_session else ""),
|
|
231
|
+
email=user_info.get("email", self.current_session.email if self.current_session else ""),
|
|
232
|
+
expires_at=time.time() + expires_in,
|
|
233
|
+
)
|
|
234
|
+
self.current_session = session
|
|
235
|
+
self._store_refresh_token(session.refresh_token, session)
|
|
236
|
+
return session
|
|
237
|
+
|
|
238
|
+
def restore_session(self) -> Optional[Session]:
|
|
239
|
+
"""Attempt to restore or refresh a session from saved token."""
|
|
240
|
+
env_token = os.getenv("MORPHY_ACCESS_TOKEN")
|
|
241
|
+
if env_token:
|
|
242
|
+
self.current_session = Session(
|
|
243
|
+
access_token=env_token,
|
|
244
|
+
refresh_token="",
|
|
245
|
+
user_id="",
|
|
246
|
+
email="",
|
|
247
|
+
expires_at=time.time() + 86400,
|
|
248
|
+
)
|
|
249
|
+
return self.current_session
|
|
250
|
+
|
|
251
|
+
refresh_token = self._get_stored_refresh_token()
|
|
252
|
+
if not refresh_token:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
return self.refresh()
|
|
257
|
+
except Exception:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def get_valid_token(self) -> str:
|
|
261
|
+
"""Return a valid access token, refreshing if necessary."""
|
|
262
|
+
if not self.current_session:
|
|
263
|
+
restored = self.restore_session()
|
|
264
|
+
if not restored:
|
|
265
|
+
env_token = os.getenv("MORPHY_ACCESS_TOKEN")
|
|
266
|
+
if env_token:
|
|
267
|
+
return env_token
|
|
268
|
+
raise AuthError("Authentication required. Please run /login.")
|
|
269
|
+
|
|
270
|
+
if self.current_session.is_expired:
|
|
271
|
+
self.refresh()
|
|
272
|
+
|
|
273
|
+
return self.current_session.access_token
|
|
274
|
+
|
|
275
|
+
def logout(self) -> None:
|
|
276
|
+
"""Sign out and invalidate session credentials."""
|
|
277
|
+
if self.current_session and self.current_session.access_token and self.supabase_key:
|
|
278
|
+
try:
|
|
279
|
+
endpoint = f"{self.supabase_url}/auth/v1/logout"
|
|
280
|
+
with httpx.Client(timeout=8.0) as client:
|
|
281
|
+
client.post(
|
|
282
|
+
endpoint,
|
|
283
|
+
headers={
|
|
284
|
+
"apikey": self.supabase_key,
|
|
285
|
+
"Authorization": f"Bearer {self.current_session.access_token}",
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
self._clear_stored_tokens()
|
|
292
|
+
self.current_session = None
|
|
293
|
+
|
|
294
|
+
def status(self) -> dict[str, Any]:
|
|
295
|
+
"""Return current status dictionary."""
|
|
296
|
+
token = os.getenv("MORPHY_ACCESS_TOKEN")
|
|
297
|
+
if token and not self.current_session:
|
|
298
|
+
return {
|
|
299
|
+
"authenticated": True,
|
|
300
|
+
"source": "MORPHY_ACCESS_TOKEN environment variable",
|
|
301
|
+
"email": "injected-environment-session",
|
|
302
|
+
"supabase_url": self.supabase_url,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if not self.current_session:
|
|
306
|
+
restored = self.restore_session()
|
|
307
|
+
if not restored:
|
|
308
|
+
return {
|
|
309
|
+
"authenticated": False,
|
|
310
|
+
"supabase_url": self.supabase_url,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
remaining_sec = max(0, int(self.current_session.expires_at - time.time()))
|
|
314
|
+
return {
|
|
315
|
+
"authenticated": True,
|
|
316
|
+
"email": self.current_session.email,
|
|
317
|
+
"user_id": self.current_session.user_id,
|
|
318
|
+
"expires_in_seconds": remaining_sec,
|
|
319
|
+
"supabase_url": self.supabase_url,
|
|
320
|
+
}
|
|
321
|
+
|