astris-python 0.1.0__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.
astris/auth/session.py ADDED
@@ -0,0 +1,241 @@
1
+ from typing import Annotated, Any, NoReturn, Self
2
+
3
+ from fastapi import Depends, HTTPException, Request, status
4
+ from fastapi.params import Depends as DependsClass
5
+ from pwdlib import PasswordHash
6
+
7
+ # Modern Argon2id hasher (pwdlib 0.3.1 / OWASP standard)
8
+ password_hash = PasswordHash.recommended()
9
+
10
+
11
+ def hash_password(password: str) -> str:
12
+ """Hash a password using modern Argon2id with automatic salt generation."""
13
+ return password_hash.hash(password)
14
+
15
+
16
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
17
+ """Verify a plain password against an Argon2id hash."""
18
+ try:
19
+ return password_hash.verify(plain_password, hashed_password)
20
+ except (ValueError, TypeError):
21
+ return False
22
+
23
+
24
+ def verify_and_update_password(
25
+ plain_password: str, hashed_password: str
26
+ ) -> tuple[bool, str | None]:
27
+ """Verify a password and return an updated hash if security parameters need upgrading."""
28
+ try:
29
+ return password_hash.verify_and_update(plain_password, hashed_password)
30
+ except (ValueError, TypeError):
31
+ return False, None
32
+
33
+
34
+ def login_user(
35
+ request: Request,
36
+ user_or_id: Any,
37
+ user_data: dict[str, Any] | None = None,
38
+ ) -> None:
39
+ """Authenticate a user by storing their ID and safe profile data in the signed session."""
40
+ user_id: int | str | None = None
41
+ extracted_data: dict[str, Any] = (
42
+ user_data.copy() if isinstance(user_data, dict) else {}
43
+ )
44
+
45
+ # 1. If an SQLModel or object instance is passed
46
+ if hasattr(user_or_id, "id"):
47
+ user_id = user_or_id.id
48
+ if not user_data:
49
+ if hasattr(user_or_id, "model_dump"):
50
+ raw_dict = user_or_id.model_dump()
51
+ elif hasattr(user_or_id, "__dict__"):
52
+ raw_dict = dict(user_or_id.__dict__)
53
+ else:
54
+ raw_dict = {"id": user_id}
55
+ # Strip sensitive database fields like passwords/hashes from the session cookie
56
+ extracted_data = {
57
+ k: v
58
+ for k, v in raw_dict.items()
59
+ if not k.startswith("_")
60
+ and k
61
+ not in (
62
+ "hashed_password",
63
+ "password",
64
+ "secret",
65
+ "password_hash",
66
+ )
67
+ }
68
+ # 2. If a dictionary is passed
69
+ elif isinstance(user_or_id, dict):
70
+ user_id = user_or_id.get("id")
71
+ if not user_data:
72
+ extracted_data = {
73
+ k: v
74
+ for k, v in user_or_id.items()
75
+ if k
76
+ not in (
77
+ "hashed_password",
78
+ "password",
79
+ "secret",
80
+ "password_hash",
81
+ )
82
+ }
83
+ # 3. If a raw ID is passed
84
+ elif isinstance(user_or_id, (int, str)):
85
+ user_id = user_or_id
86
+
87
+ if user_id is None:
88
+ raise ValueError("Could not determine user_id from the provided user object.")
89
+
90
+ if not hasattr(request, "session"):
91
+ request.state.user_id = user_id
92
+ if extracted_data:
93
+ request.state.user = extracted_data
94
+ return
95
+
96
+ request.session["user_id"] = user_id
97
+ if extracted_data:
98
+ request.session["user_data"] = extracted_data
99
+
100
+
101
+ def logout_user(request: Request) -> None:
102
+ """Terminate the current authenticated session."""
103
+ if hasattr(request, "session"):
104
+ request.session.pop("user_id", None)
105
+ request.session.pop("user_data", None)
106
+ if hasattr(request.state, "user_id"):
107
+ delattr(request.state, "user_id")
108
+ if hasattr(request.state, "user"):
109
+ delattr(request.state, "user")
110
+
111
+
112
+ def get_user_id(request: Request) -> int | str | None:
113
+ """Retrieve the current authenticated user ID from the session or request state."""
114
+ if hasattr(request, "session"):
115
+ uid = request.session.get("user_id")
116
+ if uid is not None:
117
+ return uid
118
+ return getattr(request.state, "user_id", None)
119
+
120
+
121
+ def get_auth_user(request: Request) -> dict[str, Any] | None:
122
+ """Retrieve the current authenticated user profile dictionary from the session or request state."""
123
+ user = getattr(request.state, "user", None)
124
+ if user is not None:
125
+ if isinstance(user, dict):
126
+ return user
127
+ if hasattr(user, "model_dump"):
128
+ return user.model_dump()
129
+ if hasattr(request, "session"):
130
+ user_data = request.session.get("user_data")
131
+ if isinstance(user_data, dict):
132
+ return user_data
133
+ uid = request.session.get("user_id")
134
+ if uid is not None:
135
+ return {"id": uid}
136
+ uid = getattr(request.state, "user_id", None)
137
+ if uid is not None:
138
+ return {"id": uid}
139
+ return None
140
+
141
+
142
+ def is_authenticated(request: Request) -> bool:
143
+ """Check if the current request is from an authenticated user."""
144
+ return get_user_id(request) is not None
145
+
146
+
147
+ # --- Internal Challenge Handlers ---
148
+
149
+
150
+ def _unauthorized_challenge(request: Request, redirect_url: str = "/login") -> NoReturn:
151
+ """Handle unauthenticated request via 303 redirects (Inertia/HTML) or 401 JSON."""
152
+ is_inertia = request.headers.get("X-Inertia") == "true"
153
+ accept = request.headers.get("accept", "")
154
+ if is_inertia or "text/html" in accept:
155
+ raise HTTPException(
156
+ status_code=status.HTTP_303_SEE_OTHER,
157
+ headers={"Location": redirect_url},
158
+ )
159
+ raise HTTPException(
160
+ status_code=status.HTTP_401_UNAUTHORIZED,
161
+ detail="Authentication required",
162
+ )
163
+
164
+
165
+ def _guest_challenge(request: Request, redirect_url: str = "/dashboard") -> None:
166
+ """Redirect already authenticated users to the dashboard."""
167
+ if is_authenticated(request):
168
+ raise HTTPException(
169
+ status_code=status.HTTP_303_SEE_OTHER,
170
+ headers={"Location": redirect_url},
171
+ )
172
+
173
+
174
+ # --- Internal Callables for Dependency Injection ---
175
+
176
+
177
+ def _default_auth_id_dependency(request: Request) -> int | str:
178
+ user_id = get_user_id(request)
179
+ if user_id is None:
180
+ _unauthorized_challenge(request, "/login")
181
+ return user_id
182
+
183
+
184
+ def _default_auth_user_dependency(request: Request) -> dict[str, Any]:
185
+ user = get_auth_user(request)
186
+ if user is None:
187
+ _unauthorized_challenge(request, "/login")
188
+ return user
189
+
190
+
191
+ # --- Unified Guards ---
192
+
193
+
194
+ class _AuthRequiredGuard(DependsClass):
195
+ """Authentication guard dependency.
196
+
197
+ Usage:
198
+ dependencies=[auth_required]
199
+ dependencies=[auth_required(redirect_url="/custom-login")]
200
+ """
201
+
202
+ def __init__(self, redirect_url: str = "/login") -> None:
203
+ def _dependency(request: Request) -> int | str:
204
+ user_id = get_user_id(request)
205
+ if user_id is None:
206
+ _unauthorized_challenge(request, redirect_url)
207
+ return user_id
208
+
209
+ super().__init__(dependency=_dependency)
210
+
211
+ def __call__(self, redirect_url: str = "/login", **kwargs: Any) -> Self:
212
+ url = kwargs.get("redirect_url", redirect_url)
213
+ return self.__class__(redirect_url=url)
214
+
215
+
216
+ class _GuestRequiredGuard(DependsClass):
217
+ """Guest-only guard dependency.
218
+
219
+ Usage:
220
+ dependencies=[guest_required]
221
+ dependencies=[guest_required(redirect_url="/custom-dashboard")]
222
+ """
223
+
224
+ def __init__(self, redirect_url: str = "/dashboard") -> None:
225
+ def _dependency(request: Request) -> None:
226
+ _guest_challenge(request, redirect_url)
227
+
228
+ super().__init__(dependency=_dependency)
229
+
230
+ def __call__(self, redirect_url: str = "/dashboard", **kwargs: Any) -> Self:
231
+ url = kwargs.get("redirect_url", redirect_url)
232
+ return self.__class__(redirect_url=url)
233
+
234
+
235
+ # Direct Router / Controller Guards:
236
+ auth_required = _AuthRequiredGuard()
237
+ guest_required = _GuestRequiredGuard()
238
+
239
+ # Direct Parameter Type Aliases:
240
+ AuthUser = Annotated[dict[str, Any], Depends(_default_auth_user_dependency)]
241
+ AuthUserId = Annotated[int | str, Depends(_default_auth_id_dependency)]
astris/cli.py ADDED
@@ -0,0 +1,421 @@
1
+ import io
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ import uvicorn
8
+
9
+ if sys.platform == "win32":
10
+ if isinstance(sys.stdout, io.TextIOWrapper):
11
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
12
+ if isinstance(sys.stderr, io.TextIOWrapper):
13
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
14
+
15
+ orbit_cli = typer.Typer(
16
+ name="orbit",
17
+ help="Astris CLI",
18
+ no_args_is_help=True,
19
+ add_completion=False,
20
+ )
21
+
22
+
23
+ @orbit_cli.command("list")
24
+ def list_commands(ctx: typer.Context):
25
+ """List all available Orbit commands."""
26
+ if ctx.parent:
27
+ typer.echo(ctx.parent.get_help())
28
+ else:
29
+ typer.echo(ctx.get_help())
30
+
31
+
32
+ @orbit_cli.command()
33
+ def serve(
34
+ host: str = typer.Option(
35
+ "127.0.0.1", "--host", "-h", help="Bind socket to this host"
36
+ ),
37
+ port: int = typer.Option(8000, "--port", "-p", help="Bind socket to this port"),
38
+ reload: bool = typer.Option(
39
+ True, "--reload/--no-reload", help="Enable/disable auto-reload"
40
+ ),
41
+ vite: bool = typer.Option(
42
+ True,
43
+ "--vite/--no-vite",
44
+ help="Start concurrent Vite dev server if package.json exists",
45
+ ),
46
+ ):
47
+ """Start the development server (with concurrent Vite dev server if package.json exists)."""
48
+ cwd = Path.cwd()
49
+ cwd_str = str(cwd)
50
+ if cwd_str not in sys.path:
51
+ sys.path.insert(0, cwd_str)
52
+
53
+ # If full-stack (package.json present) and vite is enabled, orchestrate Vite concurrently
54
+ package_json = cwd / "package.json"
55
+ vite_proc = None
56
+
57
+ if package_json.exists() and vite:
58
+ typer.secho(
59
+ "⚡ Full-stack project detected. Starting Vite dev server...",
60
+ fg=typer.colors.MAGENTA,
61
+ )
62
+ try:
63
+ vite_proc = subprocess.Popen(
64
+ ["npm", "run", "dev"],
65
+ cwd=cwd_str,
66
+ stdout=sys.stdout,
67
+ stderr=sys.stderr,
68
+ shell=sys.platform == "win32",
69
+ )
70
+ except (OSError, subprocess.SubprocessError) as err:
71
+ typer.secho(
72
+ f"⚠️ Could not start Vite dev server: {err}",
73
+ fg=typer.colors.YELLOW,
74
+ )
75
+
76
+ typer.secho(
77
+ f"🚀 Astris entering orbit on http://{host}:{port}", fg=typer.colors.CYAN
78
+ )
79
+
80
+ try:
81
+ reload_dirs = []
82
+ if (cwd / "app").exists():
83
+ reload_dirs.append(str(cwd / "app"))
84
+ if (cwd / "database").exists():
85
+ reload_dirs.append(str(cwd / "database"))
86
+ if not reload_dirs:
87
+ reload_dirs = [cwd_str]
88
+
89
+ uvicorn.run(
90
+ "main:app",
91
+ host=host,
92
+ port=port,
93
+ reload=reload,
94
+ reload_dirs=reload_dirs if reload else None,
95
+ reload_includes=["*.py", ".env*"] if reload else None,
96
+ reload_excludes=[
97
+ "node_modules",
98
+ "resources",
99
+ "public",
100
+ ".vite",
101
+ ".git",
102
+ "dist",
103
+ "build",
104
+ ]
105
+ if reload
106
+ else None,
107
+ app_dir=cwd_str,
108
+ )
109
+ finally:
110
+ if vite_proc:
111
+ try:
112
+ vite_proc.terminate()
113
+ vite_proc.wait(timeout=2)
114
+ except subprocess.TimeoutExpired:
115
+ vite_proc.kill()
116
+ except OSError:
117
+ pass
118
+
119
+
120
+ @orbit_cli.command("make:module")
121
+ def make_module(name: str):
122
+ """Scaffold a full domain module (e.g. 'orbit make:module billing')."""
123
+ clean_name = name.removesuffix("Module").lower()
124
+ module_dir = Path.cwd() / "app" / "modules" / clean_name
125
+
126
+ if module_dir.exists():
127
+ typer.secho(
128
+ f"Error: Module '{clean_name}' already exists!", fg=typer.colors.RED
129
+ )
130
+ raise typer.Exit(1)
131
+
132
+ module_dir.mkdir(parents=True, exist_ok=True)
133
+ (module_dir / "__init__.py").touch()
134
+
135
+ # 1. Controller stub
136
+ controller_file = module_dir / f"{clean_name}_controller.py"
137
+ controller_stub = f'''from astris.routing import Controller
138
+
139
+ controller = Controller(prefix="/{clean_name}s", tags=["{clean_name.capitalize()}"])
140
+
141
+
142
+ @controller.get("/")
143
+ async def list_{clean_name}s():
144
+ return {{"message": "Hello from {clean_name} module!"}}
145
+ '''
146
+ controller_file.write_text(controller_stub, encoding="utf-8")
147
+
148
+ # 2. Service stub (docstring is sufficient, no redundant pass)
149
+ service_file = module_dir / f"{clean_name}_service.py"
150
+ service_stub = f'''class {clean_name.capitalize()}Service:
151
+ """Business logic for {clean_name} domain."""
152
+ '''
153
+ service_file.write_text(service_stub, encoding="utf-8")
154
+
155
+ # 3. Model stub (SQLModel Table + DTOs)
156
+ model_file = module_dir / f"{clean_name}_model.py"
157
+ class_name = clean_name.capitalize()
158
+ model_stub = f"""from astris.database import Field, SQLModel
159
+
160
+
161
+ class {class_name}Base(SQLModel):
162
+ name: str = Field(index=True)
163
+
164
+
165
+ class {class_name}({class_name}Base, table=True):
166
+ id: int | None = Field(default=None, primary_key=True)
167
+
168
+
169
+ class {class_name}Create({class_name}Base):
170
+ pass
171
+
172
+
173
+ class {class_name}Public({class_name}Base):
174
+ id: int
175
+ """
176
+ model_file.write_text(model_stub, encoding="utf-8")
177
+
178
+ typer.secho(
179
+ f"✓ Created module 'app/modules/{clean_name}' with controller, service, and model",
180
+ fg=typer.colors.GREEN,
181
+ )
182
+
183
+
184
+ @orbit_cli.command("make:controller")
185
+ def make_controller(
186
+ name: str,
187
+ module: str = typer.Option(
188
+ None, "--module", "-m", help="Target module name (defaults to controller name)"
189
+ ),
190
+ ):
191
+ """Scaffold a controller inside a domain module."""
192
+ clean_name = name.removesuffix("Controller").lower()
193
+ target_module = (module or clean_name).lower()
194
+ module_dir = Path.cwd() / "app" / "modules" / target_module
195
+ module_dir.mkdir(parents=True, exist_ok=True)
196
+ (module_dir / "__init__.py").touch()
197
+
198
+ file_name = f"{clean_name}_controller.py"
199
+ target_file = module_dir / file_name
200
+
201
+ if target_file.exists():
202
+ typer.secho(
203
+ f"Error: {file_name} already exists in app/modules/{target_module}!",
204
+ fg=typer.colors.RED,
205
+ )
206
+ raise typer.Exit(1)
207
+
208
+ stub = f'''from astris.routing import Controller
209
+
210
+ controller = Controller(prefix="/{clean_name}s", tags=["{clean_name.capitalize()}"])
211
+
212
+
213
+ @controller.get("/")
214
+ async def list_{clean_name}s():
215
+ return {{"message": "Hello from {clean_name}!"}}
216
+ '''
217
+ target_file.write_text(stub, encoding="utf-8")
218
+ typer.secho(
219
+ f"✓ Created app/modules/{target_module}/{file_name}",
220
+ fg=typer.colors.GREEN,
221
+ )
222
+
223
+
224
+ @orbit_cli.command("make:model")
225
+ def make_model(
226
+ name: str,
227
+ module: str = typer.Option(
228
+ None, "--module", "-m", help="Target module name (defaults to model name)"
229
+ ),
230
+ ):
231
+ """Scaffold a SQLModel table model inside a domain module."""
232
+ clean_name = name.removesuffix("Model").lower()
233
+ class_name = name.removesuffix("Model").capitalize()
234
+ target_module = (module or clean_name).lower()
235
+ module_dir = Path.cwd() / "app" / "modules" / target_module
236
+ module_dir.mkdir(parents=True, exist_ok=True)
237
+ (module_dir / "__init__.py").touch()
238
+
239
+ file_name = f"{clean_name}_model.py"
240
+ target_file = module_dir / file_name
241
+
242
+ if target_file.exists():
243
+ typer.secho(
244
+ f"Error: {file_name} already exists in app/modules/{target_module}!",
245
+ fg=typer.colors.RED,
246
+ )
247
+ raise typer.Exit(1)
248
+
249
+ stub = f"""from astris.database import Field, SQLModel
250
+
251
+
252
+ class {class_name}Base(SQLModel):
253
+ name: str = Field(index=True)
254
+
255
+
256
+ class {class_name}({class_name}Base, table=True):
257
+ id: int | None = Field(default=None, primary_key=True)
258
+
259
+
260
+ class {class_name}Create({class_name}Base):
261
+ pass
262
+
263
+
264
+ class {class_name}Public({class_name}Base):
265
+ id: int
266
+ """
267
+ target_file.write_text(stub, encoding="utf-8")
268
+ typer.secho(
269
+ f"✓ Created app/modules/{target_module}/{file_name}",
270
+ fg=typer.colors.GREEN,
271
+ )
272
+
273
+
274
+ @orbit_cli.command("make:migration")
275
+ def make_migration(
276
+ name: str,
277
+ autogenerate: bool = typer.Option(
278
+ True,
279
+ "--autogenerate/--empty",
280
+ help="Autogenerate schema diff from SQLModel models",
281
+ ),
282
+ ):
283
+ """Generate a new versioned migration script."""
284
+ from astris.database.migrations import create_migration
285
+
286
+ try:
287
+ create_migration(message=name, autogenerate=autogenerate)
288
+ typer.secho(
289
+ f"✓ Migration '{name}' generated successfully!",
290
+ fg=typer.colors.GREEN,
291
+ )
292
+ except Exception as e:
293
+ typer.secho(f"Error generating migration: {e}", fg=typer.colors.RED)
294
+ raise typer.Exit(1) from e
295
+
296
+
297
+ @orbit_cli.command("migrate")
298
+ def migrate(
299
+ revision: str = typer.Option(
300
+ "head", "--revision", "-r", help="Target revision (default: head)"
301
+ ),
302
+ ):
303
+ """Run pending database migrations."""
304
+ from astris.database.migrations import run_migrations
305
+
306
+ try:
307
+ run_migrations(revision=revision)
308
+ typer.secho(
309
+ f"✓ Database migrated successfully to '{revision}'!",
310
+ fg=typer.colors.GREEN,
311
+ )
312
+ except Exception as e:
313
+ typer.secho(f"Error executing migrations: {e}", fg=typer.colors.RED)
314
+ raise typer.Exit(1) from e
315
+
316
+
317
+ @orbit_cli.command("migrate:rollback")
318
+ def migrate_rollback(
319
+ steps: int = typer.Option(
320
+ 1, "--steps", "-s", help="Number of migrations to roll back"
321
+ ),
322
+ ):
323
+ """Roll back database migrations by N steps."""
324
+ from astris.database.migrations import rollback_migrations
325
+
326
+ try:
327
+ target_revision = f"-{steps}"
328
+ rollback_migrations(revision=target_revision)
329
+ typer.secho(
330
+ f"✓ Rolled back {steps} migration(s) successfully!",
331
+ fg=typer.colors.GREEN,
332
+ )
333
+ except Exception as e:
334
+ typer.secho(f"Error rolling back migrations: {e}", fg=typer.colors.RED)
335
+ raise typer.Exit(1) from e
336
+
337
+
338
+ @orbit_cli.command("migrate:status")
339
+ def migrate_status():
340
+ """Display current database migration revision and head status."""
341
+ from astris.database.migrations import get_migration_status
342
+
343
+ try:
344
+ status = get_migration_status()
345
+ current = status["current_revisions"] or ["None"]
346
+ heads = status["heads"] or ["None"]
347
+ up_to_date = status["is_up_to_date"]
348
+
349
+ typer.secho(f"Current revision: {', '.join(current)}", fg=typer.colors.CYAN)
350
+ typer.secho(f"Latest head: {', '.join(heads)}", fg=typer.colors.CYAN)
351
+ if up_to_date:
352
+ typer.secho("✓ Database is up to date!", fg=typer.colors.GREEN)
353
+ else:
354
+ typer.secho(
355
+ "⚠ Pending migrations exist! Run 'orbit migrate' to apply.",
356
+ fg=typer.colors.YELLOW,
357
+ )
358
+ except Exception as e:
359
+ typer.secho(f"Error checking migration status: {e}", fg=typer.colors.RED)
360
+ raise typer.Exit(1) from e
361
+
362
+
363
+ @orbit_cli.command("key:generate")
364
+ def key_generate(
365
+ show: bool = typer.Option(
366
+ False, "--show", help="Display the generated key instead of writing to .env"
367
+ ),
368
+ ):
369
+ """Generate and set the application encryption key (APP_KEY)."""
370
+ import secrets
371
+
372
+ key = secrets.token_urlsafe(32)
373
+ if show:
374
+ typer.secho(f"APP_KEY={key}", fg=typer.colors.CYAN)
375
+ return
376
+
377
+ env_path = Path.cwd() / ".env"
378
+ if not env_path.exists():
379
+ env_path.write_text(f"APP_KEY={key}\n", encoding="utf-8")
380
+ typer.secho("✓ Created .env and set APP_KEY", fg=typer.colors.GREEN)
381
+ return
382
+
383
+ content = env_path.read_text(encoding="utf-8")
384
+ if "APP_KEY=" in content:
385
+ lines = []
386
+ replaced = False
387
+ for line in content.splitlines():
388
+ if line.startswith("APP_KEY="):
389
+ lines.append(f"APP_KEY={key}")
390
+ replaced = True
391
+ else:
392
+ lines.append(line)
393
+ if not replaced:
394
+ lines.append(f"APP_KEY={key}")
395
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
396
+ else:
397
+ env_path.write_text(content.rstrip() + f"\nAPP_KEY={key}\n", encoding="utf-8")
398
+
399
+ typer.secho("✓ Application key [APP_KEY] set successfully.", fg=typer.colors.GREEN)
400
+
401
+
402
+ @orbit_cli.command("make:auth")
403
+ def make_auth():
404
+ """Scaffold complete authentication (routes, controller, service, model, and Inertia Vue pages)."""
405
+ from astris.auth.installer import install_auth_starter
406
+
407
+ try:
408
+ install_auth_starter()
409
+ typer.secho(
410
+ "✓ Authentication scaffolding generated successfully!\n"
411
+ " - Backend: app/modules/auth (controller, service, model)\n"
412
+ " - Frontend: resources/js/Pages/Auth (Login.vue, Register.vue)\n"
413
+ " - Frontend: resources/js/Pages/Dashboard.vue\n\n"
414
+ "Next steps:\n"
415
+ ' 1. Run: uv run orbit make:migration "create_users_table"\n'
416
+ " 2. Run: uv run orbit migrate",
417
+ fg=typer.colors.GREEN,
418
+ )
419
+ except Exception as e:
420
+ typer.secho(f"Error generating auth scaffolding: {e}", fg=typer.colors.RED)
421
+ raise typer.Exit(1) from e