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/__init__.py +7 -0
- astris/assets/favicon.ico +0 -0
- astris/auth/__init__.py +29 -0
- astris/auth/installer.py +512 -0
- astris/auth/session.py +241 -0
- astris/cli.py +421 -0
- astris/config.py +50 -0
- astris/database/__init__.py +28 -0
- astris/database/migrations.py +259 -0
- astris/database/session.py +108 -0
- astris/http/__init__.py +25 -0
- astris/http/static.py +31 -0
- astris/inertia/__init__.py +8 -0
- astris/inertia/exceptions.py +133 -0
- astris/inertia/response.py +99 -0
- astris/inertia/shared.py +176 -0
- astris/inertia/vite.py +89 -0
- astris/installer.py +595 -0
- astris/kernel.py +230 -0
- astris/py.typed +0 -0
- astris/routing/__init__.py +31 -0
- astris/routing/router.py +38 -0
- astris/security/__init__.py +3 -0
- astris/security/csrf.py +100 -0
- astris_python-0.1.0.dist-info/METADATA +241 -0
- astris_python-0.1.0.dist-info/RECORD +29 -0
- astris_python-0.1.0.dist-info/WHEEL +4 -0
- astris_python-0.1.0.dist-info/entry_points.txt +5 -0
- astris_python-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|