kctl-supa 0.6.3__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.
Files changed (40) hide show
  1. kctl_supa/__init__.py +3 -0
  2. kctl_supa/__main__.py +5 -0
  3. kctl_supa/cli.py +169 -0
  4. kctl_supa/commands/__init__.py +1 -0
  5. kctl_supa/commands/advisors_cmd.py +239 -0
  6. kctl_supa/commands/auth_cmd.py +276 -0
  7. kctl_supa/commands/backup_cmd.py +126 -0
  8. kctl_supa/commands/config_cmd.py +225 -0
  9. kctl_supa/commands/cron_cmd.py +108 -0
  10. kctl_supa/commands/dashboard_cmd.py +101 -0
  11. kctl_supa/commands/db_cmd.py +229 -0
  12. kctl_supa/commands/deploy_cmd.py +123 -0
  13. kctl_supa/commands/doctor_cmd.py +289 -0
  14. kctl_supa/commands/functions_cmd.py +147 -0
  15. kctl_supa/commands/health_cmd.py +100 -0
  16. kctl_supa/commands/integrations_cmd.py +85 -0
  17. kctl_supa/commands/logs_cmd.py +115 -0
  18. kctl_supa/commands/maintenance_cmd.py +121 -0
  19. kctl_supa/commands/migrate_cmd.py +128 -0
  20. kctl_supa/commands/monitor_cmd.py +127 -0
  21. kctl_supa/commands/publications_cmd.py +113 -0
  22. kctl_supa/commands/queues_cmd.py +164 -0
  23. kctl_supa/commands/realtime_cmd.py +72 -0
  24. kctl_supa/commands/security_cmd.py +146 -0
  25. kctl_supa/commands/settings_cmd.py +114 -0
  26. kctl_supa/commands/skill_cmd.py +57 -0
  27. kctl_supa/commands/status_cmd.py +79 -0
  28. kctl_supa/commands/storage_cmd.py +255 -0
  29. kctl_supa/commands/upgrade_cmd.py +258 -0
  30. kctl_supa/commands/vault_cmd.py +101 -0
  31. kctl_supa/core/__init__.py +1 -0
  32. kctl_supa/core/callbacks.py +26 -0
  33. kctl_supa/core/client.py +203 -0
  34. kctl_supa/core/config.py +156 -0
  35. kctl_supa/core/docker.py +250 -0
  36. kctl_supa/core/exceptions.py +56 -0
  37. kctl_supa-0.6.3.dist-info/METADATA +18 -0
  38. kctl_supa-0.6.3.dist-info/RECORD +40 -0
  39. kctl_supa-0.6.3.dist-info/WHEEL +4 -0
  40. kctl_supa-0.6.3.dist-info/entry_points.txt +2 -0
kctl_supa/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Kodemeio Supabase CLI."""
2
+
3
+ __version__ = "0.6.3"
kctl_supa/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_supa."""
2
+
3
+ from kctl_supa.cli import _run
4
+
5
+ _run()
kctl_supa/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """Main CLI entry point for kctl-supa."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib import handle_cli_error
9
+
10
+ from kctl_supa import __version__
11
+ from kctl_supa.commands.advisors_cmd import app as advisors_app
12
+ from kctl_supa.commands.auth_cmd import app as auth_app
13
+ from kctl_supa.commands.backup_cmd import app as backup_app
14
+ from kctl_supa.commands.config_cmd import app as config_app
15
+ from kctl_supa.commands.cron_cmd import app as cron_app
16
+ from kctl_supa.commands.dashboard_cmd import app as dashboard_app
17
+ from kctl_supa.commands.db_cmd import app as db_app
18
+ from kctl_supa.commands.deploy_cmd import app as deploy_app
19
+ from kctl_supa.commands.doctor_cmd import app as doctor_app
20
+ from kctl_supa.commands.functions_cmd import app as functions_app
21
+ from kctl_supa.commands.health_cmd import app as health_app
22
+ from kctl_supa.commands.integrations_cmd import app as integrations_app
23
+ from kctl_supa.commands.logs_cmd import app as logs_app
24
+ from kctl_supa.commands.maintenance_cmd import app as maintenance_app
25
+ from kctl_supa.commands.migrate_cmd import app as migrate_app
26
+ from kctl_supa.commands.monitor_cmd import app as monitor_app
27
+ from kctl_supa.commands.publications_cmd import app as publications_app
28
+ from kctl_supa.commands.queues_cmd import app as queues_app
29
+ from kctl_supa.commands.realtime_cmd import app as realtime_app
30
+ from kctl_supa.commands.security_cmd import app as security_app
31
+ from kctl_supa.commands.settings_cmd import app as settings_app
32
+ from kctl_supa.commands.skill_cmd import app as skill_app
33
+ from kctl_supa.commands.status_cmd import app as status_app
34
+ from kctl_supa.commands.storage_cmd import app as storage_app
35
+ from kctl_supa.commands.upgrade_cmd import app as upgrade_app
36
+ from kctl_supa.commands.vault_cmd import app as vault_app
37
+ from kctl_supa.core.callbacks import AppContext
38
+ from kctl_supa.core.exceptions import SupabaseError
39
+ from kctl_lib.self_update import notify_if_outdated
40
+ from kctl_lib.tui import add_tui_command
41
+
42
+
43
+ def version_callback(value: bool) -> None:
44
+ if value:
45
+ typer.echo(f"kctl-supa {__version__}")
46
+ raise typer.Exit()
47
+
48
+
49
+ app = typer.Typer(
50
+ name="kctl-supa",
51
+ help="Kodemeio Supabase CLI - manage self-hosted Supabase instances.",
52
+ no_args_is_help=True,
53
+ rich_markup_mode="rich",
54
+ pretty_exceptions_enable=False,
55
+ )
56
+
57
+
58
+ @app.callback()
59
+ def main(
60
+ ctx: typer.Context,
61
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
62
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
63
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
64
+ url: Annotated[str | None, typer.Option("--url", help="Supabase URL override")] = None,
65
+ version: Annotated[
66
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
67
+ ] = False,
68
+ ) -> None:
69
+ """Kodemeio Supabase CLI."""
70
+ ctx.ensure_object(dict)
71
+ ctx.obj = AppContext(
72
+ json_mode=json_output,
73
+ quiet=quiet,
74
+ profile=profile,
75
+ url_override=url,
76
+ )
77
+ notify_if_outdated(ctx.obj.output, "kctl-supa", __version__)
78
+
79
+
80
+ _P_ADMIN = "Admin & Config"
81
+ app.add_typer(config_app, name="config", rich_help_panel=_P_ADMIN)
82
+ app.add_typer(security_app, name="security", rich_help_panel=_P_ADMIN)
83
+ app.add_typer(doctor_app, name="doctor", rich_help_panel=_P_ADMIN)
84
+ app.add_typer(vault_app, name="vault", rich_help_panel=_P_ADMIN)
85
+ app.add_typer(integrations_app, name="integrations", rich_help_panel=_P_ADMIN)
86
+ app.add_typer(settings_app, name="settings", rich_help_panel=_P_ADMIN)
87
+
88
+ _P_SERVICES = "Services"
89
+ app.add_typer(health_app, name="health", rich_help_panel=_P_SERVICES)
90
+ app.add_typer(status_app, name="status", rich_help_panel=_P_SERVICES)
91
+ app.add_typer(dashboard_app, name="dashboard", rich_help_panel=_P_SERVICES)
92
+ app.add_typer(monitor_app, name="monitor", rich_help_panel=_P_SERVICES)
93
+ app.add_typer(advisors_app, name="advisors", rich_help_panel=_P_SERVICES)
94
+
95
+ _P_DATABASE = "Database"
96
+ app.add_typer(db_app, name="db", rich_help_panel=_P_DATABASE)
97
+ app.add_typer(backup_app, name="backup", rich_help_panel=_P_DATABASE)
98
+ app.add_typer(maintenance_app, name="maintenance", rich_help_panel=_P_DATABASE)
99
+ app.add_typer(migrate_app, name="migrate", rich_help_panel=_P_DATABASE)
100
+ app.add_typer(cron_app, name="cron", rich_help_panel=_P_DATABASE)
101
+ app.add_typer(queues_app, name="queues", rich_help_panel=_P_DATABASE)
102
+
103
+ _P_AUTH = "Auth & Users"
104
+ app.add_typer(auth_app, name="auth", rich_help_panel=_P_AUTH)
105
+
106
+ _P_STORAGE = "Storage & Files"
107
+ app.add_typer(storage_app, name="storage", rich_help_panel=_P_STORAGE)
108
+
109
+ _P_RT = "Realtime & Functions"
110
+ app.add_typer(realtime_app, name="realtime", rich_help_panel=_P_RT)
111
+ app.add_typer(functions_app, name="functions", rich_help_panel=_P_RT)
112
+ app.add_typer(publications_app, name="publications", rich_help_panel=_P_RT)
113
+
114
+ _P_OPS = "Operations"
115
+ app.add_typer(logs_app, name="logs", rich_help_panel=_P_OPS)
116
+ app.add_typer(deploy_app, name="deploy", rich_help_panel=_P_OPS)
117
+ app.add_typer(upgrade_app, name="upgrade", rich_help_panel=_P_OPS)
118
+
119
+ app.add_typer(skill_app, name="skill", hidden=True)
120
+ add_tui_command(app, service_key="supa", version=__version__)
121
+
122
+
123
+ @app.command("self-update")
124
+ def self_update_cmd(ctx: typer.Context) -> None:
125
+ """Check for updates and upgrade kctl-supa."""
126
+ actx = ctx.obj
127
+ out = actx.output
128
+ from kctl_lib.self_update import check_update
129
+ from kctl_lib.self_update import update as do_update
130
+
131
+ latest = check_update("kctl-supa", __version__)
132
+ if latest:
133
+ out.info(f"Updating to {latest}...")
134
+ do_update("kctl-supa")
135
+ out.success(f"Updated to {latest}")
136
+ else:
137
+ out.success("Already up to date")
138
+
139
+
140
+ @app.command()
141
+ def completions(
142
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
143
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
144
+ ) -> None:
145
+ """Generate or install shell completions."""
146
+ from kctl_lib.completions import get_completion_script, install_completions
147
+
148
+ if install:
149
+ path = install_completions("kctl-supa", shell)
150
+ if path:
151
+ typer.echo(f"Completions installed to {path}")
152
+ else:
153
+ typer.echo(f"Could not install completions for {shell}", err=True)
154
+ raise typer.Exit(code=1)
155
+ else:
156
+ script = get_completion_script("kctl-supa", shell)
157
+ typer.echo(script)
158
+
159
+
160
+ def _run() -> None:
161
+ """Entry point with error handling."""
162
+ try:
163
+ app()
164
+ except SupabaseError as e:
165
+ handle_cli_error(e)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ _run()
@@ -0,0 +1 @@
1
+ """CLI command modules for kctl-supa."""
@@ -0,0 +1,239 @@
1
+ """Security and performance advisor commands for kctl-supa."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich import print as rprint
7
+ from rich.table import Table
8
+
9
+ from kctl_supa.core.callbacks import AppContext
10
+ from kctl_supa.core.docker import DockerOps
11
+ from kctl_supa.core.exceptions import DockerError
12
+
13
+ app = typer.Typer(help="Security and performance advisors.")
14
+
15
+
16
+ def _get_docker(actx: AppContext) -> DockerOps:
17
+ return DockerOps(actx.config)
18
+
19
+
20
+ def _run_psql(actx: AppContext, query: str) -> str:
21
+ out = actx.output
22
+ try:
23
+ docker = _get_docker(actx)
24
+ result = docker.psql(query)
25
+ docker.close()
26
+ return result
27
+ except DockerError as exc:
28
+ out.error(str(exc))
29
+ raise typer.Exit(1) from exc
30
+
31
+
32
+ def _extract_last_value(raw: str) -> str:
33
+ lines = [line.strip() for line in raw.splitlines() if line.strip()]
34
+ return lines[-1] if lines else "N/A"
35
+
36
+
37
+ @app.command()
38
+ def security(ctx: typer.Context) -> None:
39
+ """Security audit: tables without RLS, public schemas, superuser roles."""
40
+ actx: AppContext = ctx.obj
41
+
42
+ docker = _get_docker(actx)
43
+ checks: list[tuple[str, str, str]] = []
44
+
45
+ try:
46
+ no_rls = docker.psql(
47
+ "SELECT count(*) FROM pg_tables "
48
+ "WHERE schemaname = 'public' AND tablename NOT IN ("
49
+ "SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public'"
50
+ ")",
51
+ )
52
+ val = _extract_last_value(no_rls)
53
+ n = int(val) if val.isdigit() else 0
54
+ checks.append(("Tables without RLS", val, "[red]WARN[/red]" if n > 0 else "[green]OK[/green]"))
55
+ except DockerError:
56
+ checks.append(("Tables without RLS", "error", "[red]ERROR[/red]"))
57
+
58
+ try:
59
+ superusers = docker.psql("SELECT count(*) FROM pg_roles WHERE rolsuper = true")
60
+ val = _extract_last_value(superusers)
61
+ checks.append(("Superuser roles", val, "[yellow]INFO[/yellow]"))
62
+ except DockerError:
63
+ checks.append(("Superuser roles", "error", "[red]ERROR[/red]"))
64
+
65
+ try:
66
+ exposed = docker.psql(
67
+ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'",
68
+ )
69
+ val = _extract_last_value(exposed)
70
+ checks.append(("Public schema tables", val, "[yellow]INFO[/yellow]"))
71
+ except DockerError:
72
+ checks.append(("Public schema tables", "error", "[red]ERROR[/red]"))
73
+
74
+ docker.close()
75
+
76
+ table = Table(title="Security Advisor", show_header=True, header_style="bold cyan")
77
+ table.add_column("Check", style="cyan")
78
+ table.add_column("Value")
79
+ table.add_column("Status")
80
+ for check_name, value, status in checks:
81
+ table.add_row(check_name, value, status)
82
+ rprint(table)
83
+
84
+
85
+ @app.command()
86
+ def performance(ctx: typer.Context) -> None:
87
+ """Performance advisor: cache hit ratio, unused indexes, table bloat."""
88
+ actx: AppContext = ctx.obj
89
+
90
+ docker = _get_docker(actx)
91
+ checks: list[tuple[str, str, str]] = []
92
+
93
+ try:
94
+ cache = docker.psql(
95
+ "SELECT round(100.0 * sum(blks_hit) / nullif(sum(blks_hit) + sum(blks_read), 0), 2) FROM pg_stat_database",
96
+ )
97
+ val = _extract_last_value(cache)
98
+ try:
99
+ pct = float(val)
100
+ except ValueError:
101
+ pct = 0
102
+ status = "[green]OK[/green]" if pct >= 99 else "[yellow]WARN[/yellow]" if pct >= 95 else "[red]LOW[/red]"
103
+ checks.append(("Cache hit ratio", f"{val}%", status))
104
+ except DockerError:
105
+ checks.append(("Cache hit ratio", "error", "[red]ERROR[/red]"))
106
+
107
+ try:
108
+ unused = docker.psql(
109
+ "SELECT count(*) FROM pg_stat_user_indexes WHERE idx_scan = 0",
110
+ )
111
+ val = _extract_last_value(unused)
112
+ n = int(val) if val.isdigit() else 0
113
+ status = "[green]OK[/green]" if n == 0 else "[yellow]WARN[/yellow]"
114
+ checks.append(("Unused indexes", val, status))
115
+ except DockerError:
116
+ checks.append(("Unused indexes", "error", "[red]ERROR[/red]"))
117
+
118
+ try:
119
+ dead = docker.psql(
120
+ "SELECT sum(n_dead_tup) FROM pg_stat_user_tables",
121
+ )
122
+ val = _extract_last_value(dead)
123
+ try:
124
+ n = int(val)
125
+ except ValueError:
126
+ n = 0
127
+ status = "[green]OK[/green]" if n < 10000 else "[yellow]WARN[/yellow]" if n < 100000 else "[red]HIGH[/red]"
128
+ checks.append(("Dead tuples", val, status))
129
+ except DockerError:
130
+ checks.append(("Dead tuples", "error", "[red]ERROR[/red]"))
131
+
132
+ docker.close()
133
+
134
+ table = Table(title="Performance Advisor", show_header=True, header_style="bold cyan")
135
+ table.add_column("Check", style="cyan")
136
+ table.add_column("Value")
137
+ table.add_column("Status")
138
+ for check_name, value, status in checks:
139
+ table.add_row(check_name, value, status)
140
+ rprint(table)
141
+
142
+
143
+ @app.command()
144
+ def queries(ctx: typer.Context) -> None:
145
+ """Query performance: top queries by total time (requires pg_stat_statements)."""
146
+ actx: AppContext = ctx.obj
147
+ out = actx.output
148
+ check = _run_psql(actx, "SELECT count(*) FROM pg_extension WHERE extname = 'pg_stat_statements'")
149
+ if check.strip().splitlines()[-1].strip() == "0":
150
+ out.warn("pg_stat_statements not installed. Enable it: CREATE EXTENSION pg_stat_statements;")
151
+ return
152
+ result = _run_psql(
153
+ actx,
154
+ "SELECT calls, round(total_exec_time::numeric, 2) AS total_ms, "
155
+ "round(mean_exec_time::numeric, 2) AS mean_ms, "
156
+ "left(query, 80) AS query "
157
+ "FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20",
158
+ )
159
+ typer.echo(result)
160
+
161
+
162
+ @app.command(name="indexes")
163
+ def index_advisor(ctx: typer.Context) -> None:
164
+ """Index advisor: unused indexes and sequential scan ratios."""
165
+ actx: AppContext = ctx.obj
166
+ out = actx.output
167
+
168
+ out.info("Unused indexes (0 scans):")
169
+ unused = _run_psql(
170
+ actx,
171
+ "SELECT schemaname, relname AS table, indexrelname AS index, "
172
+ "pg_size_pretty(pg_relation_size(indexrelid)) AS size "
173
+ "FROM pg_stat_user_indexes WHERE idx_scan = 0 "
174
+ "ORDER BY pg_relation_size(indexrelid) DESC",
175
+ )
176
+ typer.echo(unused)
177
+
178
+ out.info("Tables with high sequential scan ratio:")
179
+ seq_ratio = _run_psql(
180
+ actx,
181
+ "SELECT schemaname, relname AS table, "
182
+ "seq_scan, idx_scan, "
183
+ "CASE WHEN seq_scan + idx_scan > 0 "
184
+ "THEN round(100.0 * seq_scan / (seq_scan + idx_scan), 1) ELSE 0 END AS seq_pct "
185
+ "FROM pg_stat_user_tables "
186
+ "WHERE seq_scan + idx_scan > 100 "
187
+ "ORDER BY seq_pct DESC LIMIT 20",
188
+ )
189
+ typer.echo(seq_ratio)
190
+
191
+
192
+ @app.command(name="rls-audit")
193
+ def rls_audit(ctx: typer.Context) -> None:
194
+ """RLS audit: tables without policies, permissive gaps, role coverage."""
195
+ actx: AppContext = ctx.obj
196
+ out = actx.output
197
+
198
+ docker = _get_docker(actx)
199
+
200
+ out.info("Tables WITHOUT any RLS policy (exposed to anon/authenticated):")
201
+ no_policy = docker.psql(
202
+ "SELECT schemaname, tablename, rowsecurity "
203
+ "FROM pg_tables "
204
+ "WHERE schemaname IN ('public', 'storage') "
205
+ "AND tablename NOT IN (SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = pg_tables.schemaname) "
206
+ "ORDER BY schemaname, tablename",
207
+ )
208
+ typer.echo(no_policy)
209
+
210
+ out.info("Tables with RLS DISABLED (bypasses all policies):")
211
+ rls_disabled = docker.psql(
212
+ "SELECT n.nspname AS schema, c.relname AS table "
213
+ "FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid "
214
+ "WHERE c.relkind = 'r' AND NOT c.relrowsecurity "
215
+ "AND n.nspname IN ('public', 'storage') "
216
+ "ORDER BY n.nspname, c.relname",
217
+ )
218
+ typer.echo(rls_disabled)
219
+
220
+ out.info("All RLS policies (schema, table, policy, roles, command):")
221
+ all_policies = docker.psql(
222
+ "SELECT schemaname, tablename, policyname, permissive, roles, cmd "
223
+ "FROM pg_policies "
224
+ "WHERE schemaname IN ('public', 'storage', 'auth') "
225
+ "ORDER BY schemaname, tablename, policyname",
226
+ )
227
+ typer.echo(all_policies)
228
+
229
+ out.info("Tables with FORCE ROW SECURITY (applies to table owner too):")
230
+ forced = docker.psql(
231
+ "SELECT n.nspname AS schema, c.relname AS table "
232
+ "FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid "
233
+ "WHERE c.relkind = 'r' AND c.relforcerowsecurity "
234
+ "AND n.nspname IN ('public', 'storage') "
235
+ "ORDER BY n.nspname, c.relname",
236
+ )
237
+ typer.echo(forced)
238
+
239
+ docker.close()
@@ -0,0 +1,276 @@
1
+ """Authentication and user management commands for kctl-supa.
2
+
3
+ Authentication and user management.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich import print as rprint
12
+ from rich.table import Table
13
+
14
+ from kctl_supa.core.callbacks import AppContext
15
+ from kctl_supa.core.client import SupabaseClient
16
+ from kctl_supa.core.docker import DockerOps
17
+ from kctl_supa.core.exceptions import DockerError, SupabaseError
18
+
19
+ app = typer.Typer(help="Authentication and user management.")
20
+
21
+
22
+ def _get_client(actx: AppContext) -> SupabaseClient:
23
+ cfg = actx.config
24
+ return SupabaseClient(base_url=cfg.url, service_role_key=cfg.service_role_key, anon_key=cfg.anon_key)
25
+
26
+
27
+ def _get_docker(actx: AppContext) -> DockerOps:
28
+ return DockerOps(actx.config)
29
+
30
+
31
+ @app.command(name="list")
32
+ def list_users(
33
+ ctx: typer.Context,
34
+ page: Annotated[int, typer.Option("--page", help="Page number")] = 1,
35
+ per_page: Annotated[int, typer.Option("--per-page", help="Users per page")] = 50,
36
+ ) -> None:
37
+ """List users."""
38
+ actx: AppContext = ctx.obj
39
+ out = actx.output
40
+
41
+ try:
42
+ client = _get_client(actx)
43
+ result = client.auth_list_users(page=page, per_page=per_page)
44
+ client.close()
45
+ except SupabaseError as exc:
46
+ out.error(str(exc))
47
+ raise typer.Exit(1) from exc
48
+
49
+ users = result if isinstance(result, list) else result.get("users", [])
50
+
51
+ if not users:
52
+ out.warn("No users found.")
53
+ return
54
+
55
+ table = Table(title=f"Auth Users (page {page})", show_header=True, header_style="bold cyan")
56
+ table.add_column("ID", style="dim")
57
+ table.add_column("Email", style="cyan")
58
+ table.add_column("Created At")
59
+ table.add_column("Confirmed")
60
+
61
+ for user in users:
62
+ uid = user.get("id", "")
63
+ email = user.get("email", "")
64
+ created = user.get("created_at", "")[:19] if user.get("created_at") else ""
65
+ confirmed = "[green]yes[/green]" if user.get("email_confirmed_at") else "[red]no[/red]"
66
+ table.add_row(uid, email, created, confirmed)
67
+
68
+ rprint(table)
69
+
70
+
71
+ @app.command()
72
+ def get(
73
+ ctx: typer.Context,
74
+ user_id: Annotated[str, typer.Argument(help="User ID")],
75
+ ) -> None:
76
+ """Get user details by ID."""
77
+ actx: AppContext = ctx.obj
78
+ out = actx.output
79
+
80
+ try:
81
+ client = _get_client(actx)
82
+ user = client.auth_get_user(user_id)
83
+ client.close()
84
+ except SupabaseError as exc:
85
+ out.error(str(exc))
86
+ raise typer.Exit(1) from exc
87
+
88
+ out.kv("ID", user.get("id", ""))
89
+ out.kv("Email", user.get("email", ""))
90
+ out.kv("Phone", user.get("phone", "") or "[dim]not set[/dim]")
91
+ out.kv("Created", user.get("created_at", "")[:19])
92
+ out.kv("Updated", user.get("updated_at", "")[:19])
93
+ out.kv("Email confirmed", "yes" if user.get("email_confirmed_at") else "no")
94
+ out.kv("Role", user.get("role", ""))
95
+ ban_duration = user.get("ban_duration")
96
+ if ban_duration and ban_duration != "none":
97
+ out.kv("Banned", f"[red]yes ({ban_duration})[/red]")
98
+ else:
99
+ out.kv("Banned", "no")
100
+
101
+
102
+ @app.command()
103
+ def create(
104
+ ctx: typer.Context,
105
+ email: Annotated[str, typer.Option("--email", help="User email", prompt=True)],
106
+ password: Annotated[str, typer.Option("--password", help="User password", prompt=True, hide_input=True)],
107
+ ) -> None:
108
+ """Create a new user."""
109
+ actx: AppContext = ctx.obj
110
+ out = actx.output
111
+
112
+ try:
113
+ client = _get_client(actx)
114
+ user = client.auth_create_user(email=email, password=password)
115
+ client.close()
116
+ except SupabaseError as exc:
117
+ out.error(str(exc))
118
+ raise typer.Exit(1) from exc
119
+
120
+ out.success(f"User created: {user.get('id')}")
121
+ out.kv("Email", email)
122
+
123
+
124
+ @app.command()
125
+ def delete(
126
+ ctx: typer.Context,
127
+ user_id: Annotated[str, typer.Argument(help="User ID to delete")],
128
+ confirm: Annotated[bool, typer.Option("--confirm", help="Skip confirmation prompt")] = False,
129
+ ) -> None:
130
+ """Delete a user."""
131
+ actx: AppContext = ctx.obj
132
+ out = actx.output
133
+
134
+ if not confirm:
135
+ if not typer.confirm(f"Delete user '{user_id}'?"):
136
+ raise typer.Exit(0)
137
+
138
+ try:
139
+ client = _get_client(actx)
140
+ client.auth_delete_user(user_id)
141
+ client.close()
142
+ except SupabaseError as exc:
143
+ out.error(str(exc))
144
+ raise typer.Exit(1) from exc
145
+
146
+ out.success(f"User '{user_id}' deleted")
147
+
148
+
149
+ @app.command()
150
+ def ban(
151
+ ctx: typer.Context,
152
+ user_id: Annotated[str, typer.Argument(help="User ID to ban")],
153
+ ) -> None:
154
+ """Ban a user."""
155
+ actx: AppContext = ctx.obj
156
+ out = actx.output
157
+
158
+ try:
159
+ client = _get_client(actx)
160
+ client.auth_update_user(user_id, ban_duration="876600h")
161
+ client.close()
162
+ except SupabaseError as exc:
163
+ out.error(str(exc))
164
+ raise typer.Exit(1) from exc
165
+
166
+ out.success(f"User '{user_id}' banned")
167
+
168
+
169
+ @app.command()
170
+ def unban(
171
+ ctx: typer.Context,
172
+ user_id: Annotated[str, typer.Argument(help="User ID to unban")],
173
+ ) -> None:
174
+ """Unban a user."""
175
+ actx: AppContext = ctx.obj
176
+ out = actx.output
177
+
178
+ try:
179
+ client = _get_client(actx)
180
+ client.auth_update_user(user_id, ban_duration="none")
181
+ client.close()
182
+ except SupabaseError as exc:
183
+ out.error(str(exc))
184
+ raise typer.Exit(1) from exc
185
+
186
+ out.success(f"User '{user_id}' unbanned")
187
+
188
+
189
+ @app.command()
190
+ def stats(ctx: typer.Context) -> None:
191
+ """Show auth user statistics."""
192
+ actx: AppContext = ctx.obj
193
+ out = actx.output
194
+
195
+ try:
196
+ docker = _get_docker(actx)
197
+ total = docker.psql("SELECT count(*) FROM auth.users")
198
+ confirmed = docker.psql("SELECT count(*) FROM auth.users WHERE email_confirmed_at IS NOT NULL")
199
+ banned = docker.psql("SELECT count(*) FROM auth.users WHERE banned_until IS NOT NULL AND banned_until > now()")
200
+ docker.close()
201
+ except DockerError as exc:
202
+ out.error(str(exc))
203
+ raise typer.Exit(1) from exc
204
+
205
+ def _extract_count(raw: str) -> str:
206
+ for line in raw.splitlines():
207
+ line = line.strip()
208
+ if line.isdigit():
209
+ return line
210
+ return raw.strip()
211
+
212
+ out.kv("Total users", _extract_count(total))
213
+ out.kv("Confirmed", _extract_count(confirmed))
214
+ out.kv("Banned", _extract_count(banned))
215
+
216
+
217
+ @app.command()
218
+ def providers(ctx: typer.Context) -> None:
219
+ """List configured auth providers."""
220
+ actx: AppContext = ctx.obj
221
+ out = actx.output
222
+
223
+ try:
224
+ docker = _get_docker(actx)
225
+ result = docker.docker_exec(
226
+ "auth",
227
+ "env | grep -i 'GOTRUE_EXTERNAL_' | sort",
228
+ )
229
+ docker.close()
230
+ except DockerError as exc:
231
+ out.error(str(exc))
232
+ raise typer.Exit(1) from exc
233
+
234
+ if not result.strip():
235
+ out.warn("No external providers configured.")
236
+ return
237
+
238
+ table = Table(title="Auth Providers", show_header=True, header_style="bold cyan")
239
+ table.add_column("Provider", style="cyan")
240
+ table.add_column("Status")
241
+
242
+ seen: set[str] = set()
243
+ for line in result.splitlines():
244
+ line = line.strip()
245
+ if "ENABLED" in line:
246
+ parts = line.split("=", 1)
247
+ var_name = parts[0]
248
+ enabled = parts[1].strip().lower() == "true" if len(parts) > 1 else False
249
+ provider = var_name.replace("GOTRUE_EXTERNAL_", "").replace("_ENABLED", "").lower()
250
+ if provider not in seen:
251
+ seen.add(provider)
252
+ status = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
253
+ table.add_row(provider, status)
254
+
255
+ rprint(table)
256
+
257
+
258
+ @app.command()
259
+ def policies(ctx: typer.Context) -> None:
260
+ """List RLS policies on auth schema tables."""
261
+ actx: AppContext = ctx.obj
262
+ out = actx.output
263
+
264
+ try:
265
+ docker = _get_docker(actx)
266
+ result = docker.psql(
267
+ "SELECT tablename, policyname, permissive, roles, cmd, qual "
268
+ "FROM pg_policies WHERE schemaname = 'auth' "
269
+ "ORDER BY tablename, policyname",
270
+ )
271
+ docker.close()
272
+ except DockerError as exc:
273
+ out.error(str(exc))
274
+ raise typer.Exit(1) from exc
275
+
276
+ typer.echo(result)