parad 2.2.3__tar.gz → 2.2.6__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.
- {parad-2.2.3 → parad-2.2.6}/PKG-INFO +37 -19
- parad-2.2.6/README.md +153 -0
- {parad-2.2.3 → parad-2.2.6}/parad/__init__.py +3 -3
- {parad-2.2.3 → parad-2.2.6}/parad/cli.py +3 -0
- parad-2.2.6/parad/commands/auth.py +110 -0
- parad-2.2.6/parad/commands/config_cmd.py +32 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/connect.py +8 -9
- {parad-2.2.3 → parad-2.2.6}/parad/commands/init.py +8 -9
- parad-2.2.6/parad/commands/url.py +62 -0
- parad-2.2.6/parad/config.py +309 -0
- {parad-2.2.3 → parad-2.2.6}/parad/connection.py +36 -41
- {parad-2.2.3 → parad-2.2.6}/parad/gateway.py +28 -21
- {parad-2.2.3 → parad-2.2.6}/parad.egg-info/PKG-INFO +37 -19
- {parad-2.2.3 → parad-2.2.6}/parad.egg-info/SOURCES.txt +1 -0
- {parad-2.2.3 → parad-2.2.6}/pyproject.toml +1 -1
- {parad-2.2.3 → parad-2.2.6}/tests/test_connection.py +116 -40
- {parad-2.2.3 → parad-2.2.6}/tests/test_workflow.py +10 -20
- parad-2.2.3/README.md +0 -135
- parad-2.2.3/parad/commands/auth.py +0 -109
- parad-2.2.3/parad/commands/config_cmd.py +0 -27
- parad-2.2.3/parad/config.py +0 -154
- {parad-2.2.3 → parad-2.2.6}/parad/__main__.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/__init__.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/backups.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/databases.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/projects.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/query.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/shell.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/status.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/sync.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/versions.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/commands/watch.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/crypto.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/dbapi.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/engine.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/sqlalchemy.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/state.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/types.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad/watcher.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad.egg-info/dependency_links.txt +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad.egg-info/entry_points.txt +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad.egg-info/requires.txt +0 -0
- {parad-2.2.3 → parad-2.2.6}/parad.egg-info/top_level.txt +0 -0
- {parad-2.2.3 → parad-2.2.6}/setup.cfg +0 -0
- {parad-2.2.3 → parad-2.2.6}/tests/test_crypto.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/tests/test_engine.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/tests/test_sqlalchemy.py +0 -0
- {parad-2.2.3 → parad-2.2.6}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parad
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.6
|
|
4
4
|
Summary: Drop-in encrypted database with cloud sync — connect() and go
|
|
5
5
|
Author-email: nexuss0781 <nexuss0781@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
|
|
@@ -46,9 +46,9 @@ sync daemon version-stores your changes automatically. No manual push needed.
|
|
|
46
46
|
```python
|
|
47
47
|
from parad import connect
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
db = connect(url="parad://
|
|
49
|
+
# Exchange a Nexuss API key once for a Paradox key, then use the Paradox key
|
|
50
|
+
# in the connection URL to auto-provision the project and database.
|
|
51
|
+
db = connect(url="parad://pk_example@local/myproj/mydb?passphrase=secret")
|
|
52
52
|
|
|
53
53
|
# Build SQL offline like any SQLite database ...
|
|
54
54
|
db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
@@ -75,10 +75,15 @@ db = connect("mydb", passphrase="secret", auto_sync=False)
|
|
|
75
75
|
### CLI
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
# Create
|
|
79
|
-
parad
|
|
80
|
-
|
|
78
|
+
# Create a new encrypted database and print the canonical URL deliberately
|
|
79
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
80
|
+
parad init mydb --project myproject --print-database-url
|
|
81
|
+
# Retrieve the existing canonical URL; local values are checked first,
|
|
82
|
+
# then the owner-authenticated gateway recovery endpoint is used
|
|
83
|
+
parad url
|
|
84
|
+
parad url --print-database-url
|
|
81
85
|
# Push to cloud
|
|
86
|
+
|
|
82
87
|
parad push
|
|
83
88
|
|
|
84
89
|
# Pull latest
|
|
@@ -108,32 +113,43 @@ parad shell
|
|
|
108
113
|
| `parad update <table> <set> <where>` | Update rows |
|
|
109
114
|
| `parad delete <table> <where>` | Delete rows |
|
|
110
115
|
| `parad shell` | Interactive SQL REPL |
|
|
111
|
-
| `parad config show/set` | Manage config |
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
| `parad config show/set` | Manage config; secret-bearing fields are redacted |
|
|
117
|
+
| `parad url [name]` | Retrieve the canonical database_url using local values, owner-authenticated server recovery, then legacy fallback |
|
|
118
|
+
| `parad url register <url>` | Explicitly store a known canonical URL on the gateway without snapshot mutation |
|
|
119
|
+
| `parad database-url [name]` | Alias for `parad url` |
|
|
120
|
+
## Canonical DATABASE_URL first
|
|
121
|
+
Use one canonical connection value for new applications and deployments. After `parad init` provisions the project/database, it persists `database_url` in `~/.paradox/config.json` and prints a redacted URL by default:
|
|
117
122
|
```bash
|
|
123
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
118
124
|
parad init mydb --project myproject
|
|
119
125
|
```
|
|
120
|
-
|
|
121
|
-
Use `--print-database-url` only when intentionally copying the complete secret-bearing URL into a secret manager:
|
|
122
|
-
|
|
126
|
+
Print the complete secret-bearing value only when intentionally copying it into a secret manager:
|
|
123
127
|
```bash
|
|
124
128
|
parad init mydb --project myproject --print-database-url
|
|
125
129
|
```
|
|
130
|
+
For an existing database, retrieve the saved canonical value. If local values are unavailable, the CLI resolves the owned project/database and calls the read-only owner-authenticated gateway recovery endpoint:
|
|
131
|
+
```bash
|
|
132
|
+
parad url
|
|
133
|
+
parad url --print-database-url
|
|
134
|
+
```
|
|
135
|
+
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then server recovery, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. Project-scoped connections register the canonical URL with the gateway, which stores it encrypted at rest. The full value is returned only with `--print-database-url`; ordinary output is redacted. If the server has no stored URL and the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
126
136
|
|
|
137
|
+
For a database created before server URL storage existed, provide the already-known URL once with `parad url register '<url>'`. This updates only the encrypted server field; it does not run `init`, push, pull, or overwrite snapshots. A URL that was never previously stored cannot be recovered from server metadata alone.
|
|
127
138
|
Applications can use the same single value:
|
|
128
|
-
|
|
129
139
|
```python
|
|
130
140
|
import os
|
|
131
141
|
from parad import connect
|
|
132
|
-
|
|
133
142
|
db = connect(url=os.environ["DATABASE_URL"])
|
|
134
143
|
```
|
|
135
144
|
|
|
136
|
-
|
|
145
|
+
For a pre-feature database whose canonical URL is already known, register it explicitly without opening or syncing the database:
|
|
146
|
+
```python
|
|
147
|
+
from parad import register_canonical_database_url
|
|
148
|
+
register_canonical_database_url(os.environ["DATABASE_URL"])
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
An explicit `url` argument is strongest. Explicit `name` or `db_path` options remain available for legacy target selection.
|
|
152
|
+
|
|
137
153
|
|
|
138
154
|
## SQLAlchemy
|
|
139
155
|
|
|
@@ -156,6 +172,8 @@ Config lives at `~/.paradox/config.json`:
|
|
|
156
172
|
|
|
157
173
|
## Security
|
|
158
174
|
|
|
175
|
+
The gateway stores the canonical `database_url` encrypted at rest in `paradox_dbs.database_url_encrypted`. Set a stable `DATABASE_URL_ENCRYPTION_KEY` on the gateway. The redacted endpoint is safe for metadata checks; full recovery requires the owner API key and an explicit reveal command.
|
|
176
|
+
|
|
159
177
|
- AES-256-CBC encryption at rest
|
|
160
178
|
- PBKDF2-HMAC-SHA512 key derivation (256k iterations)
|
|
161
179
|
- Your passphrase never leaves your machine
|
parad-2.2.6/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# parad
|
|
2
|
+
|
|
3
|
+
Encrypted local-first SQLite with Telegram cloud sync — a git-like, zero-cost
|
|
4
|
+
workflow database. Telegram is the disk: every change you commit becomes a
|
|
5
|
+
version-stored snapshot you can revert to anytime.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install parad
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Python SDK (developer workflow — auto-sync on by default)
|
|
16
|
+
|
|
17
|
+
Connect with a postgres-like connection string, write SQL offline, and the
|
|
18
|
+
sync daemon version-stores your changes automatically. No manual push needed.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from parad import connect
|
|
22
|
+
|
|
23
|
+
# Exchange a Nexuss API key once for a Paradox key, then use the Paradox key
|
|
24
|
+
# in the connection URL to auto-provision the project and database.
|
|
25
|
+
db = connect(url="parad://pk_example@local/myproj/mydb?passphrase=secret")
|
|
26
|
+
|
|
27
|
+
# Build SQL offline like any SQLite database ...
|
|
28
|
+
db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
29
|
+
db.execute("INSERT INTO users (name) VALUES (?)", ("alice",))
|
|
30
|
+
db.commit()
|
|
31
|
+
|
|
32
|
+
# ... and it's already pushed to the cloud as a new snapshot version (~2s).
|
|
33
|
+
# Revert anytime (from the CLI):
|
|
34
|
+
# !parad rollback 1
|
|
35
|
+
|
|
36
|
+
db.close()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Offline? Keep writing — changes are flagged `dirty` and batch-pushed as new
|
|
40
|
+
versions the moment you reconnect. On conflicts your local data always wins
|
|
41
|
+
(never silently dropped).
|
|
42
|
+
|
|
43
|
+
Local-only is just as easy:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
db = connect("mydb", passphrase="secret", auto_sync=False)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### CLI
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Create a new encrypted database and print the canonical URL deliberately
|
|
53
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
54
|
+
parad init mydb --project myproject --print-database-url
|
|
55
|
+
# Retrieve the existing canonical URL; local values are checked first,
|
|
56
|
+
# then the owner-authenticated gateway recovery endpoint is used
|
|
57
|
+
parad url
|
|
58
|
+
parad url --print-database-url
|
|
59
|
+
# Push to cloud
|
|
60
|
+
|
|
61
|
+
parad push
|
|
62
|
+
|
|
63
|
+
# Pull latest
|
|
64
|
+
parad pull
|
|
65
|
+
|
|
66
|
+
# Check status
|
|
67
|
+
parad status
|
|
68
|
+
|
|
69
|
+
# Interactive SQL
|
|
70
|
+
parad shell
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Commands
|
|
74
|
+
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `parad init <name>` | Create encrypted DB + register with gateway; emit canonical DATABASE_URL |
|
|
78
|
+
| `parad push` | Push database to Telegram cloud |
|
|
79
|
+
| `parad pull [version]` | Pull latest or specific version |
|
|
80
|
+
| `parad sync` | Push then pull |
|
|
81
|
+
| `parad status` | Local vs remote version |
|
|
82
|
+
| `parad versions` | List all remote versions |
|
|
83
|
+
| `parad rollback <ver>` | Rollback to previous version |
|
|
84
|
+
| `parad exec <sql>` | Run raw SQL |
|
|
85
|
+
| `parad insert <table> <json>` | Insert a row |
|
|
86
|
+
| `parad select <table> [where]` | Query rows |
|
|
87
|
+
| `parad update <table> <set> <where>` | Update rows |
|
|
88
|
+
| `parad delete <table> <where>` | Delete rows |
|
|
89
|
+
| `parad shell` | Interactive SQL REPL |
|
|
90
|
+
| `parad config show/set` | Manage config; secret-bearing fields are redacted |
|
|
91
|
+
| `parad url [name]` | Retrieve the canonical database_url using local values, owner-authenticated server recovery, then legacy fallback |
|
|
92
|
+
| `parad url register <url>` | Explicitly store a known canonical URL on the gateway without snapshot mutation |
|
|
93
|
+
| `parad database-url [name]` | Alias for `parad url` |
|
|
94
|
+
## Canonical DATABASE_URL first
|
|
95
|
+
Use one canonical connection value for new applications and deployments. After `parad init` provisions the project/database, it persists `database_url` in `~/.paradox/config.json` and prints a redacted URL by default:
|
|
96
|
+
```bash
|
|
97
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
98
|
+
parad init mydb --project myproject
|
|
99
|
+
```
|
|
100
|
+
Print the complete secret-bearing value only when intentionally copying it into a secret manager:
|
|
101
|
+
```bash
|
|
102
|
+
parad init mydb --project myproject --print-database-url
|
|
103
|
+
```
|
|
104
|
+
For an existing database, retrieve the saved canonical value. If local values are unavailable, the CLI resolves the owned project/database and calls the read-only owner-authenticated gateway recovery endpoint:
|
|
105
|
+
```bash
|
|
106
|
+
parad url
|
|
107
|
+
parad url --print-database-url
|
|
108
|
+
```
|
|
109
|
+
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then server recovery, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. Project-scoped connections register the canonical URL with the gateway, which stores it encrypted at rest. The full value is returned only with `--print-database-url`; ordinary output is redacted. If the server has no stored URL and the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
110
|
+
|
|
111
|
+
For a database created before server URL storage existed, provide the already-known URL once with `parad url register '<url>'`. This updates only the encrypted server field; it does not run `init`, push, pull, or overwrite snapshots. A URL that was never previously stored cannot be recovered from server metadata alone.
|
|
112
|
+
Applications can use the same single value:
|
|
113
|
+
```python
|
|
114
|
+
import os
|
|
115
|
+
from parad import connect
|
|
116
|
+
db = connect(url=os.environ["DATABASE_URL"])
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For a pre-feature database whose canonical URL is already known, register it explicitly without opening or syncing the database:
|
|
120
|
+
```python
|
|
121
|
+
from parad import register_canonical_database_url
|
|
122
|
+
register_canonical_database_url(os.environ["DATABASE_URL"])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
An explicit `url` argument is strongest. Explicit `name` or `db_path` options remain available for legacy target selection.
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
## SQLAlchemy
|
|
129
|
+
|
|
130
|
+
Install the optional integration with `pip install "parad[sqlalchemy]"`, then use the same canonical URL with `create_engine("parad://...")`. The ORM, Core, DB-API, and encrypted lifecycle examples are documented in [`docs/SQLALCHEMY.md`](docs/SQLALCHEMY.md).
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
Config lives at `~/.paradox/config.json`:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"database_url": "parad://<api-key>@local/project/mydb?gateway=https%3A%2F%2F...&passphrase=...",
|
|
139
|
+
"database_path": "~/.paradox/data.db",
|
|
140
|
+
"sync": {
|
|
141
|
+
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
142
|
+
"api_key": "pk_..."
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Security
|
|
148
|
+
|
|
149
|
+
The gateway stores the canonical `database_url` encrypted at rest in `paradox_dbs.database_url_encrypted`. Set a stable `DATABASE_URL_ENCRYPTION_KEY` on the gateway. The redacted endpoint is safe for metadata checks; full recovery requires the owner API key and an explicit reveal command.
|
|
150
|
+
|
|
151
|
+
- AES-256-CBC encryption at rest
|
|
152
|
+
- PBKDF2-HMAC-SHA512 key derivation (256k iterations)
|
|
153
|
+
- Your passphrase never leaves your machine
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from parad.connection import connect, ParadConnection, parse_url, generate_url, redact_url, db_state_key, generate_passphrase
|
|
4
4
|
from parad.engine import Engine
|
|
5
|
-
from parad.config import load_config, get_passphrase, get_connection_url
|
|
5
|
+
from parad.config import load_config, get_passphrase, get_canonical_database_url, recover_canonical_database_url, register_canonical_database_url, get_connection_url
|
|
6
6
|
|
|
7
|
-
__version__ = "2.2.
|
|
8
|
-
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "redact_url", "db_state_key", "generate_passphrase", "Engine", "load_config"]
|
|
7
|
+
__version__ = "2.2.6"
|
|
8
|
+
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "redact_url", "db_state_key", "generate_passphrase", "Engine", "load_config", "get_passphrase", "get_canonical_database_url", "recover_canonical_database_url", "register_canonical_database_url", "get_connection_url"]
|
|
@@ -24,6 +24,7 @@ from parad.commands.projects import project_group
|
|
|
24
24
|
from parad.commands.databases import db_group
|
|
25
25
|
from parad.commands.versions import version_group
|
|
26
26
|
from parad.commands.backups import backup_group
|
|
27
|
+
from parad.commands.url import url_command, database_url_command
|
|
27
28
|
|
|
28
29
|
main.add_command(connect)
|
|
29
30
|
main.add_command(init)
|
|
@@ -46,3 +47,5 @@ main.add_command(project_group)
|
|
|
46
47
|
main.add_command(db_group)
|
|
47
48
|
main.add_command(version_group)
|
|
48
49
|
main.add_command(backup_group)
|
|
50
|
+
main.add_command(url_command)
|
|
51
|
+
main.add_command(database_url_command)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""parad auth — authenticate with Paradox or Nexuss Auth API keys."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click import ClickException
|
|
5
|
+
from parad.config import load_config, save_config, set_config_value
|
|
6
|
+
from parad.gateway import GatewayClient, GatewayError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group("auth")
|
|
10
|
+
def auth_group():
|
|
11
|
+
"""Manage authentication."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def authenticate_api_key(config, api_key: str) -> tuple[GatewayClient, dict]:
|
|
16
|
+
"""Validate a Paradox key or exchange a Nexuss key without persisting nxa_."""
|
|
17
|
+
supplied = api_key.strip()
|
|
18
|
+
if not supplied:
|
|
19
|
+
raise ClickException("Provide a Paradox pk_ key or a Nexuss Auth nxa_ key")
|
|
20
|
+
gateway = GatewayClient(config.sync.gateway_url)
|
|
21
|
+
if supplied.startswith("nxa_"):
|
|
22
|
+
result = gateway.exchange_nexuss_api_key(supplied)
|
|
23
|
+
resolved_key = result.get("api_key", "")
|
|
24
|
+
if not resolved_key.startswith("pk_"):
|
|
25
|
+
raise ClickException("Nexuss Auth exchange did not return a Paradox API key")
|
|
26
|
+
gateway.api_key = resolved_key
|
|
27
|
+
else:
|
|
28
|
+
gateway.api_key = supplied
|
|
29
|
+
result = {"api_key": supplied, **gateway.get_me()}
|
|
30
|
+
set_config_value("sync.api_key", gateway.api_key)
|
|
31
|
+
config.sync.api_key = gateway.api_key
|
|
32
|
+
return gateway, result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@auth_group.command("register")
|
|
36
|
+
def register():
|
|
37
|
+
"""Explain the passwordless Nexuss Auth registration flow."""
|
|
38
|
+
raise ClickException(
|
|
39
|
+
"Paradox accounts are created through Nexuss Auth. Sign in with Google in the Paradox web flow, "
|
|
40
|
+
"then run 'parad auth login --api-key <nexuss nxa_ key>'."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@auth_group.command("login")
|
|
45
|
+
@click.option("--api-key", envvar="PARADOX_API_KEY", help="Paradox pk_ key or Nexuss Auth nxa_ key")
|
|
46
|
+
def login(api_key: str | None):
|
|
47
|
+
"""Authenticate using a Paradox key or exchange a Nexuss Auth key."""
|
|
48
|
+
config = load_config()
|
|
49
|
+
supplied = api_key or click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
50
|
+
try:
|
|
51
|
+
_, result = authenticate_api_key(config, supplied)
|
|
52
|
+
click.echo(f"✓ Logged in as {result.get('username', '')}")
|
|
53
|
+
except GatewayError as e:
|
|
54
|
+
click.echo(f"✗ Login failed: {e}")
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ensure_auth(gw):
|
|
59
|
+
"""Ensure user is authenticated. Prompt for an API key if needed.
|
|
60
|
+
|
|
61
|
+
In non-interactive mode (CI/cloud), raises an error instead of prompting.
|
|
62
|
+
"""
|
|
63
|
+
if gw.api_key:
|
|
64
|
+
try:
|
|
65
|
+
gw.get_me()
|
|
66
|
+
return
|
|
67
|
+
except GatewayError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# Non-interactive mode: raise clear error
|
|
71
|
+
import sys
|
|
72
|
+
if not sys.stdin.isatty():
|
|
73
|
+
raise ClickException(
|
|
74
|
+
"Not authenticated. Set PARADOX_API_KEY environment variable or run 'parad auth login' first."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
click.echo("Authentication required.")
|
|
78
|
+
api_key = click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
config = load_config()
|
|
82
|
+
resolved, _ = authenticate_api_key(config, api_key)
|
|
83
|
+
gw.api_key = resolved.api_key
|
|
84
|
+
if gw.api_key:
|
|
85
|
+
click.echo("✓ Authentication successful")
|
|
86
|
+
else:
|
|
87
|
+
raise ClickException("Authentication succeeded but no Paradox API key was received")
|
|
88
|
+
except GatewayError as e:
|
|
89
|
+
raise ClickException(f"Authentication failed: {e}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require_auth(gw):
|
|
93
|
+
"""Require authentication. Raises ClickException if auth fails."""
|
|
94
|
+
_ensure_auth(gw)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@auth_group.command("status")
|
|
98
|
+
def auth_status():
|
|
99
|
+
"""Show current authentication status."""
|
|
100
|
+
config = load_config()
|
|
101
|
+
if not config.sync.api_key:
|
|
102
|
+
click.echo("Not logged in.")
|
|
103
|
+
return
|
|
104
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
105
|
+
try:
|
|
106
|
+
me = gw.get_me()
|
|
107
|
+
click.echo(f"Logged in as: {me.get('username', 'unknown')} ({me.get('email', '')})")
|
|
108
|
+
click.echo(f"User ID: {me.get('id', 'unknown')}")
|
|
109
|
+
except GatewayError:
|
|
110
|
+
click.echo("API key invalid. Run: parad auth login")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""parad config — View and update configuration."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from parad.config import load_config, set_config_value, save_config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group("config")
|
|
8
|
+
def config_group():
|
|
9
|
+
"""Manage parad configuration."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@config_group.command("show")
|
|
14
|
+
def config_show():
|
|
15
|
+
"""Show current configuration with secret-bearing fields redacted."""
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
config = load_config().model_dump()
|
|
19
|
+
config["database_url"] = "<redacted>" if config.get("database_url") else ""
|
|
20
|
+
config.setdefault("encryption", {})["passphrase"] = "<redacted>" if config.get("encryption", {}).get("passphrase") else ""
|
|
21
|
+
config.setdefault("sync", {})["api_key"] = "<redacted>" if config.get("sync", {}).get("api_key") else ""
|
|
22
|
+
click.echo(json.dumps(config, indent=2))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@config_group.command("set")
|
|
26
|
+
@click.argument("key")
|
|
27
|
+
@click.argument("value")
|
|
28
|
+
def config_set(key: str, value: str):
|
|
29
|
+
"""Set a config value: parad config set sync.api_key pk_xxx"""
|
|
30
|
+
set_config_value(key, value)
|
|
31
|
+
shown = "<redacted>" if any(part.lower() in {"database_url", "api_key", "passphrase", "password", "token"} for part in key.split(".")) else value
|
|
32
|
+
click.echo(f"✓ Set {key} = {shown}")
|
|
@@ -6,12 +6,13 @@ from parad.config import load_config, save_config, config_dir, gateway_db_name,
|
|
|
6
6
|
from parad.connection import db_state_key
|
|
7
7
|
from parad.engine import Engine
|
|
8
8
|
from parad.gateway import GatewayClient, GatewayError
|
|
9
|
+
from parad.commands.auth import authenticate_api_key
|
|
9
10
|
from parad.state import get_remote_version, set_remote_version, set_last_local_hash
|
|
10
11
|
from parad.watcher import is_running, get_daemon_pid, start_daemon
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _ensure_auth(config):
|
|
14
|
-
"""Auto-authenticate: prompt for
|
|
15
|
+
"""Auto-authenticate: prompt for an API key if no valid token.
|
|
15
16
|
|
|
16
17
|
In non-interactive mode (CI/cloud), raises an error instead of prompting.
|
|
17
18
|
"""
|
|
@@ -32,18 +33,16 @@ def _ensure_auth(config):
|
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
click.echo("Authentication required.")
|
|
35
|
-
|
|
36
|
-
password = click.prompt("Password", hide_input=True)
|
|
36
|
+
api_key = click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
config.sync.api_key = token
|
|
39
|
+
resolved, _ = authenticate_api_key(config, api_key)
|
|
40
|
+
gw.api_key = resolved.api_key
|
|
41
|
+
if gw.api_key:
|
|
42
|
+
config.sync.api_key = gw.api_key
|
|
44
43
|
click.echo("✓ Authentication successful")
|
|
45
44
|
else:
|
|
46
|
-
raise click.ClickException("
|
|
45
|
+
raise click.ClickException("Authentication succeeded but no Paradox API key was received")
|
|
47
46
|
except GatewayError as e:
|
|
48
47
|
raise click.ClickException(f"Authentication failed: {e}")
|
|
49
48
|
|
|
@@ -6,12 +6,13 @@ from parad.config import load_config, save_config, config_dir, gateway_db_name,
|
|
|
6
6
|
from parad.connection import db_state_key, generate_url, redact_url
|
|
7
7
|
from parad.engine import Engine
|
|
8
8
|
from parad.gateway import GatewayClient, GatewayError
|
|
9
|
+
from parad.commands.auth import authenticate_api_key
|
|
9
10
|
from parad.state import set_remote_version, set_last_local_hash
|
|
10
11
|
from parad.watcher import is_running
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _ensure_auth(config):
|
|
14
|
-
"""Auto-authenticate: prompt for
|
|
15
|
+
"""Auto-authenticate: prompt for an API key if no valid token.
|
|
15
16
|
|
|
16
17
|
In non-interactive mode (CI/cloud), raises an error instead of prompting.
|
|
17
18
|
"""
|
|
@@ -32,18 +33,16 @@ def _ensure_auth(config):
|
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
click.echo("Authentication required.")
|
|
35
|
-
|
|
36
|
-
password = click.prompt("Password", hide_input=True)
|
|
36
|
+
api_key = click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
config.sync.api_key = token
|
|
39
|
+
resolved, _ = authenticate_api_key(config, api_key)
|
|
40
|
+
gw.api_key = resolved.api_key
|
|
41
|
+
if gw.api_key:
|
|
42
|
+
config.sync.api_key = gw.api_key
|
|
44
43
|
click.echo("✓ Authentication successful")
|
|
45
44
|
else:
|
|
46
|
-
raise click.ClickException("
|
|
45
|
+
raise click.ClickException("Authentication succeeded but no Paradox API key was received")
|
|
47
46
|
except GatewayError as e:
|
|
48
47
|
raise click.ClickException(f"Authentication failed: {e}")
|
|
49
48
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Canonical database URL retrieval commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from parad.config import recover_canonical_database_url, register_canonical_database_url
|
|
8
|
+
from parad.connection import redact_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _emit_url(name: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
12
|
+
database_url = recover_canonical_database_url(name)
|
|
13
|
+
displayed = database_url if print_database_url else redact_url(database_url)
|
|
14
|
+
if json_mode:
|
|
15
|
+
click.echo(json.dumps({"database_url": displayed}))
|
|
16
|
+
else:
|
|
17
|
+
click.echo(f"DATABASE_URL: {displayed}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _register_url(database_url: str, print_database_url: bool, json_mode: bool) -> None:
|
|
21
|
+
registered = register_canonical_database_url(database_url)
|
|
22
|
+
displayed = registered if print_database_url else redact_url(registered)
|
|
23
|
+
if json_mode:
|
|
24
|
+
click.echo(json.dumps({"registered": True, "database_url": displayed}))
|
|
25
|
+
else:
|
|
26
|
+
click.echo(f"Registered DATABASE_URL: {displayed}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _handle_url_command(
|
|
30
|
+
name: str | None,
|
|
31
|
+
database_url: str | None,
|
|
32
|
+
print_database_url: bool,
|
|
33
|
+
json_mode: bool,
|
|
34
|
+
) -> None:
|
|
35
|
+
if name == "register":
|
|
36
|
+
if not database_url:
|
|
37
|
+
raise click.UsageError("Usage: parad url register <canonical-url>")
|
|
38
|
+
_register_url(database_url, print_database_url, json_mode)
|
|
39
|
+
return
|
|
40
|
+
if database_url:
|
|
41
|
+
raise click.UsageError("Only `register` accepts a second argument")
|
|
42
|
+
_emit_url(name, print_database_url, json_mode)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.command("url")
|
|
46
|
+
@click.argument("name", required=False)
|
|
47
|
+
@click.argument("database_url", required=False)
|
|
48
|
+
@click.option("--print-database-url", is_flag=True, help="Print the full secret-bearing DATABASE_URL")
|
|
49
|
+
@click.option("--json", "json_mode", is_flag=True, help="Print JSON output")
|
|
50
|
+
def url_command(name: str | None, database_url: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
51
|
+
"""Retrieve or explicitly register the canonical database_url."""
|
|
52
|
+
_handle_url_command(name, database_url, print_database_url, json_mode)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.command("database-url")
|
|
56
|
+
@click.argument("name", required=False)
|
|
57
|
+
@click.argument("database_url", required=False)
|
|
58
|
+
@click.option("--print-database-url", is_flag=True, help="Print the full secret-bearing DATABASE_URL")
|
|
59
|
+
@click.option("--json", "json_mode", is_flag=True, help="Print JSON output")
|
|
60
|
+
def database_url_command(name: str | None, database_url: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
61
|
+
"""Alias for ``parad url``."""
|
|
62
|
+
_handle_url_command(name, database_url, print_database_url, json_mode)
|