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.
Files changed (48) hide show
  1. {parad-2.2.3 → parad-2.2.6}/PKG-INFO +37 -19
  2. parad-2.2.6/README.md +153 -0
  3. {parad-2.2.3 → parad-2.2.6}/parad/__init__.py +3 -3
  4. {parad-2.2.3 → parad-2.2.6}/parad/cli.py +3 -0
  5. parad-2.2.6/parad/commands/auth.py +110 -0
  6. parad-2.2.6/parad/commands/config_cmd.py +32 -0
  7. {parad-2.2.3 → parad-2.2.6}/parad/commands/connect.py +8 -9
  8. {parad-2.2.3 → parad-2.2.6}/parad/commands/init.py +8 -9
  9. parad-2.2.6/parad/commands/url.py +62 -0
  10. parad-2.2.6/parad/config.py +309 -0
  11. {parad-2.2.3 → parad-2.2.6}/parad/connection.py +36 -41
  12. {parad-2.2.3 → parad-2.2.6}/parad/gateway.py +28 -21
  13. {parad-2.2.3 → parad-2.2.6}/parad.egg-info/PKG-INFO +37 -19
  14. {parad-2.2.3 → parad-2.2.6}/parad.egg-info/SOURCES.txt +1 -0
  15. {parad-2.2.3 → parad-2.2.6}/pyproject.toml +1 -1
  16. {parad-2.2.3 → parad-2.2.6}/tests/test_connection.py +116 -40
  17. {parad-2.2.3 → parad-2.2.6}/tests/test_workflow.py +10 -20
  18. parad-2.2.3/README.md +0 -135
  19. parad-2.2.3/parad/commands/auth.py +0 -109
  20. parad-2.2.3/parad/commands/config_cmd.py +0 -27
  21. parad-2.2.3/parad/config.py +0 -154
  22. {parad-2.2.3 → parad-2.2.6}/parad/__main__.py +0 -0
  23. {parad-2.2.3 → parad-2.2.6}/parad/commands/__init__.py +0 -0
  24. {parad-2.2.3 → parad-2.2.6}/parad/commands/backups.py +0 -0
  25. {parad-2.2.3 → parad-2.2.6}/parad/commands/databases.py +0 -0
  26. {parad-2.2.3 → parad-2.2.6}/parad/commands/projects.py +0 -0
  27. {parad-2.2.3 → parad-2.2.6}/parad/commands/query.py +0 -0
  28. {parad-2.2.3 → parad-2.2.6}/parad/commands/shell.py +0 -0
  29. {parad-2.2.3 → parad-2.2.6}/parad/commands/status.py +0 -0
  30. {parad-2.2.3 → parad-2.2.6}/parad/commands/sync.py +0 -0
  31. {parad-2.2.3 → parad-2.2.6}/parad/commands/versions.py +0 -0
  32. {parad-2.2.3 → parad-2.2.6}/parad/commands/watch.py +0 -0
  33. {parad-2.2.3 → parad-2.2.6}/parad/crypto.py +0 -0
  34. {parad-2.2.3 → parad-2.2.6}/parad/dbapi.py +0 -0
  35. {parad-2.2.3 → parad-2.2.6}/parad/engine.py +0 -0
  36. {parad-2.2.3 → parad-2.2.6}/parad/sqlalchemy.py +0 -0
  37. {parad-2.2.3 → parad-2.2.6}/parad/state.py +0 -0
  38. {parad-2.2.3 → parad-2.2.6}/parad/types.py +0 -0
  39. {parad-2.2.3 → parad-2.2.6}/parad/watcher.py +0 -0
  40. {parad-2.2.3 → parad-2.2.6}/parad.egg-info/dependency_links.txt +0 -0
  41. {parad-2.2.3 → parad-2.2.6}/parad.egg-info/entry_points.txt +0 -0
  42. {parad-2.2.3 → parad-2.2.6}/parad.egg-info/requires.txt +0 -0
  43. {parad-2.2.3 → parad-2.2.6}/parad.egg-info/top_level.txt +0 -0
  44. {parad-2.2.3 → parad-2.2.6}/setup.cfg +0 -0
  45. {parad-2.2.3 → parad-2.2.6}/tests/test_crypto.py +0 -0
  46. {parad-2.2.3 → parad-2.2.6}/tests/test_engine.py +0 -0
  47. {parad-2.2.3 → parad-2.2.6}/tests/test_sqlalchemy.py +0 -0
  48. {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
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
- # Auto-login (email:password), auto-provision project + database on the
50
- # gateway, open/create the local encrypted SQLite file.
51
- db = connect(url="parad://alice@example.com:secretpw@local/myproj/mydb?passphrase=secret")
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 an encrypted database
79
- parad init mydb
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
- ## Canonical DATABASE_URL
114
-
115
- After `parad init` successfully provisions the project and database, Parad persists the complete connection URL in `config.json` as `database_url` and prints a redacted form:
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
- An explicit `url`, `name`, or `db_path` argument takes precedence over an ambient `DATABASE_URL`. Existing connection strings and config-based workflows remain supported.
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.3"
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 credentials if no valid token.
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
- email = click.prompt("Email")
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
- result = gw.login(email, password)
40
- token = result.get("access_token") or result.get("api_key")
41
- if token:
42
- set_config_value("sync.api_key", token)
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("Login succeeded but no token received")
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 credentials if no valid token.
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
- email = click.prompt("Email")
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
- result = gw.login(email, password)
40
- token = result.get("access_token") or result.get("api_key")
41
- if token:
42
- set_config_value("sync.api_key", token)
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("Login succeeded but no token received")
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)