vibetuner 2.26.6__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.
- vibetuner/__init__.py +2 -0
- vibetuner/__main__.py +4 -0
- vibetuner/cli/__init__.py +141 -0
- vibetuner/cli/run.py +160 -0
- vibetuner/cli/scaffold.py +187 -0
- vibetuner/config.py +143 -0
- vibetuner/context.py +28 -0
- vibetuner/frontend/__init__.py +107 -0
- vibetuner/frontend/deps.py +41 -0
- vibetuner/frontend/email.py +45 -0
- vibetuner/frontend/hotreload.py +13 -0
- vibetuner/frontend/lifespan.py +37 -0
- vibetuner/frontend/middleware.py +151 -0
- vibetuner/frontend/oauth.py +196 -0
- vibetuner/frontend/routes/__init__.py +12 -0
- vibetuner/frontend/routes/auth.py +156 -0
- vibetuner/frontend/routes/debug.py +414 -0
- vibetuner/frontend/routes/health.py +37 -0
- vibetuner/frontend/routes/language.py +43 -0
- vibetuner/frontend/routes/meta.py +55 -0
- vibetuner/frontend/routes/user.py +94 -0
- vibetuner/frontend/templates.py +176 -0
- vibetuner/logging.py +87 -0
- vibetuner/models/__init__.py +14 -0
- vibetuner/models/blob.py +89 -0
- vibetuner/models/email_verification.py +84 -0
- vibetuner/models/mixins.py +76 -0
- vibetuner/models/oauth.py +57 -0
- vibetuner/models/registry.py +15 -0
- vibetuner/models/types.py +16 -0
- vibetuner/models/user.py +91 -0
- vibetuner/mongo.py +33 -0
- vibetuner/paths.py +250 -0
- vibetuner/services/__init__.py +0 -0
- vibetuner/services/blob.py +175 -0
- vibetuner/services/email.py +50 -0
- vibetuner/tasks/__init__.py +0 -0
- vibetuner/tasks/lifespan.py +28 -0
- vibetuner/tasks/worker.py +15 -0
- vibetuner/templates/email/magic_link.html.jinja +17 -0
- vibetuner/templates/email/magic_link.txt.jinja +5 -0
- vibetuner/templates/frontend/base/favicons.html.jinja +1 -0
- vibetuner/templates/frontend/base/footer.html.jinja +3 -0
- vibetuner/templates/frontend/base/header.html.jinja +0 -0
- vibetuner/templates/frontend/base/opengraph.html.jinja +7 -0
- vibetuner/templates/frontend/base/skeleton.html.jinja +45 -0
- vibetuner/templates/frontend/debug/collections.html.jinja +105 -0
- vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
- vibetuner/templates/frontend/debug/index.html.jinja +85 -0
- vibetuner/templates/frontend/debug/info.html.jinja +258 -0
- vibetuner/templates/frontend/debug/users.html.jinja +139 -0
- vibetuner/templates/frontend/debug/version.html.jinja +55 -0
- vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
- vibetuner/templates/frontend/email_sent.html.jinja +83 -0
- vibetuner/templates/frontend/index.html.jinja +20 -0
- vibetuner/templates/frontend/lang/select.html.jinja +4 -0
- vibetuner/templates/frontend/login.html.jinja +89 -0
- vibetuner/templates/frontend/meta/browserconfig.xml.jinja +10 -0
- vibetuner/templates/frontend/meta/robots.txt.jinja +3 -0
- vibetuner/templates/frontend/meta/site.webmanifest.jinja +7 -0
- vibetuner/templates/frontend/meta/sitemap.xml.jinja +6 -0
- vibetuner/templates/frontend/user/edit.html.jinja +86 -0
- vibetuner/templates/frontend/user/profile.html.jinja +157 -0
- vibetuner/templates/markdown/.placeholder +0 -0
- vibetuner/templates.py +146 -0
- vibetuner/time.py +57 -0
- vibetuner/versioning.py +12 -0
- vibetuner-2.26.6.dist-info/METADATA +241 -0
- vibetuner-2.26.6.dist-info/RECORD +71 -0
- vibetuner-2.26.6.dist-info/WHEEL +4 -0
- vibetuner-2.26.6.dist-info/entry_points.txt +3 -0
vibetuner/__init__.py
ADDED
vibetuner/__main__.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# ABOUTME: Core CLI setup with AsyncTyper wrapper and base configuration
|
|
2
|
+
# ABOUTME: Provides main CLI entry point and logging configuration
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import inspect
|
|
5
|
+
from functools import partial, wraps
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import asyncer
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from vibetuner.cli.run import run_app
|
|
16
|
+
from vibetuner.cli.scaffold import scaffold_app
|
|
17
|
+
from vibetuner.logging import LogLevel, logger, setup_logging
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncTyper(typer.Typer):
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
25
|
+
kwargs.setdefault("no_args_is_help", True)
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def maybe_run_async(decorator, f):
|
|
30
|
+
if inspect.iscoroutinefunction(f):
|
|
31
|
+
|
|
32
|
+
@wraps(f)
|
|
33
|
+
def runner(*args, **kwargs):
|
|
34
|
+
return asyncer.runnify(f)(*args, **kwargs)
|
|
35
|
+
|
|
36
|
+
decorator(runner)
|
|
37
|
+
else:
|
|
38
|
+
decorator(f)
|
|
39
|
+
return f
|
|
40
|
+
|
|
41
|
+
def callback(self, *args, **kwargs):
|
|
42
|
+
decorator = super().callback(*args, **kwargs)
|
|
43
|
+
return partial(self.maybe_run_async, decorator)
|
|
44
|
+
|
|
45
|
+
def command(self, *args, **kwargs):
|
|
46
|
+
decorator = super().command(*args, **kwargs)
|
|
47
|
+
return partial(self.maybe_run_async, decorator)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_app_help():
|
|
51
|
+
try:
|
|
52
|
+
from vibetuner.config import settings
|
|
53
|
+
|
|
54
|
+
return f"{settings.project.project_name.title()} CLI"
|
|
55
|
+
except (RuntimeError, ImportError):
|
|
56
|
+
return "Vibetuner CLI"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app = AsyncTyper(help=_get_app_help())
|
|
60
|
+
|
|
61
|
+
LOG_LEVEL_OPTION = typer.Option(
|
|
62
|
+
LogLevel.INFO,
|
|
63
|
+
"--log-level",
|
|
64
|
+
"-l",
|
|
65
|
+
case_sensitive=False,
|
|
66
|
+
help="Set the logging level",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.callback()
|
|
71
|
+
def callback(log_level: LogLevel | None = LOG_LEVEL_OPTION) -> None:
|
|
72
|
+
"""Initialize logging and other global settings."""
|
|
73
|
+
setup_logging(level=log_level)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def version(
|
|
78
|
+
show_app: bool = typer.Option(
|
|
79
|
+
False,
|
|
80
|
+
"--app",
|
|
81
|
+
"-a",
|
|
82
|
+
help="Show app settings version even if not in a project directory",
|
|
83
|
+
),
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Show version information."""
|
|
86
|
+
try:
|
|
87
|
+
# Get vibetuner package version
|
|
88
|
+
vibetuner_version = importlib.metadata.version("vibetuner")
|
|
89
|
+
except importlib.metadata.PackageNotFoundError:
|
|
90
|
+
vibetuner_version = "unknown"
|
|
91
|
+
|
|
92
|
+
# Create table for nice display
|
|
93
|
+
table = Table(title="Version Information")
|
|
94
|
+
table.add_column("Component", style="cyan", no_wrap=True)
|
|
95
|
+
table.add_column("Version", style="green", no_wrap=True)
|
|
96
|
+
|
|
97
|
+
# Always show vibetuner package version
|
|
98
|
+
table.add_row("vibetuner package", vibetuner_version)
|
|
99
|
+
|
|
100
|
+
# Show app version if requested or if in a project
|
|
101
|
+
try:
|
|
102
|
+
from vibetuner.config import CoreConfiguration
|
|
103
|
+
|
|
104
|
+
settings = CoreConfiguration()
|
|
105
|
+
table.add_row(f"{settings.project.project_name} settings", settings.version)
|
|
106
|
+
except Exception:
|
|
107
|
+
if show_app:
|
|
108
|
+
table.add_row("app settings", "not in project directory")
|
|
109
|
+
# else: don't show app version if not in project and not requested
|
|
110
|
+
|
|
111
|
+
console.print(table)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def core_template_symlink(
|
|
116
|
+
target: Annotated[
|
|
117
|
+
Path,
|
|
118
|
+
typer.Argument(
|
|
119
|
+
help="Path where the 'core' symlink should be created or updated",
|
|
120
|
+
),
|
|
121
|
+
],
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Create or update a 'core' symlink to the package templates directory."""
|
|
124
|
+
from vibetuner.paths import create_core_templates_symlink
|
|
125
|
+
|
|
126
|
+
create_core_templates_symlink(target)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
app.add_typer(run_app, name="run")
|
|
130
|
+
app.add_typer(scaffold_app, name="scaffold")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
import_module("app.cli")
|
|
134
|
+
except ModuleNotFoundError:
|
|
135
|
+
# Silent pass for missing app.cli module (expected in some projects)
|
|
136
|
+
pass
|
|
137
|
+
except ImportError as e:
|
|
138
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
139
|
+
logger.warning(
|
|
140
|
+
f"Failed to import app.cli: {e}. User CLI commands will not be available."
|
|
141
|
+
)
|
vibetuner/cli/run.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# ABOUTME: Run commands for starting the application in different modes
|
|
2
|
+
# ABOUTME: Supports dev/prod modes for frontend and worker services
|
|
3
|
+
import os
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
run_app = typer.Typer(
|
|
13
|
+
help="Run the application in different modes", no_args_is_help=True
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@run_app.command(name="dev")
|
|
18
|
+
def dev(
|
|
19
|
+
service: Annotated[
|
|
20
|
+
str, typer.Argument(help="Service to run: 'frontend' or 'worker'")
|
|
21
|
+
] = "frontend",
|
|
22
|
+
port: int = typer.Option(
|
|
23
|
+
None, help="Port to run on (8000 for frontend, 11111 for worker)"
|
|
24
|
+
),
|
|
25
|
+
host: str = typer.Option("0.0.0.0", help="Host to bind to (frontend only)"), # noqa: S104
|
|
26
|
+
workers_count: int = typer.Option(
|
|
27
|
+
1, "--workers", help="Number of worker processes"
|
|
28
|
+
),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Run in development mode with hot reload (frontend or worker)."""
|
|
31
|
+
os.environ["DEBUG"] = "1"
|
|
32
|
+
|
|
33
|
+
if service == "worker":
|
|
34
|
+
# Worker mode
|
|
35
|
+
from streaq.cli import main as streaq_main
|
|
36
|
+
|
|
37
|
+
worker_port = port if port else 11111
|
|
38
|
+
console.print(
|
|
39
|
+
f"[green]Starting worker in dev mode on port {worker_port}[/green]"
|
|
40
|
+
)
|
|
41
|
+
console.print("[dim]Hot reload enabled[/dim]")
|
|
42
|
+
|
|
43
|
+
if workers_count > 1:
|
|
44
|
+
console.print(
|
|
45
|
+
"[yellow]Warning: Multiple workers not supported in dev mode, using 1[/yellow]"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Call streaq programmatically
|
|
49
|
+
streaq_main(
|
|
50
|
+
worker_path="vibetuner.tasks.worker.worker",
|
|
51
|
+
workers=1,
|
|
52
|
+
reload=True,
|
|
53
|
+
verbose=True,
|
|
54
|
+
web=True,
|
|
55
|
+
host="0.0.0.0", # noqa: S104
|
|
56
|
+
port=worker_port,
|
|
57
|
+
)
|
|
58
|
+
elif service == "frontend":
|
|
59
|
+
# Frontend mode
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
from granian import Granian
|
|
63
|
+
from granian.constants import Interfaces
|
|
64
|
+
|
|
65
|
+
frontend_port = port if port else 8000
|
|
66
|
+
console.print(
|
|
67
|
+
f"[green]Starting frontend in dev mode on {host}:{frontend_port}[/green]"
|
|
68
|
+
)
|
|
69
|
+
console.print("[dim]Watching for changes in src/ and templates/[/dim]")
|
|
70
|
+
|
|
71
|
+
# Define paths to watch for changes
|
|
72
|
+
reload_paths = [
|
|
73
|
+
Path("src/app"),
|
|
74
|
+
Path("templates/frontend"),
|
|
75
|
+
Path("templates/email"),
|
|
76
|
+
Path("templates/markdown"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
server = Granian(
|
|
80
|
+
target="vibetuner.frontend:app",
|
|
81
|
+
address=host,
|
|
82
|
+
port=frontend_port,
|
|
83
|
+
interface=Interfaces.ASGI,
|
|
84
|
+
workers=workers_count,
|
|
85
|
+
reload=True,
|
|
86
|
+
reload_paths=reload_paths,
|
|
87
|
+
log_level="info",
|
|
88
|
+
log_access=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
server.serve()
|
|
92
|
+
else:
|
|
93
|
+
console.print(f"[red]Error: Unknown service '{service}'[/red]")
|
|
94
|
+
console.print("[yellow]Valid services: 'frontend' or 'worker'[/yellow]")
|
|
95
|
+
raise typer.Exit(code=1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@run_app.command(name="prod")
|
|
99
|
+
def prod(
|
|
100
|
+
service: Annotated[
|
|
101
|
+
str, typer.Argument(help="Service to run: 'frontend' or 'worker'")
|
|
102
|
+
] = "frontend",
|
|
103
|
+
port: int = typer.Option(
|
|
104
|
+
None, help="Port to run on (8000 for frontend, 11111 for worker)"
|
|
105
|
+
),
|
|
106
|
+
host: str = typer.Option("0.0.0.0", help="Host to bind to (frontend only)"), # noqa: S104
|
|
107
|
+
workers_count: int = typer.Option(
|
|
108
|
+
4, "--workers", help="Number of worker processes"
|
|
109
|
+
),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Run in production mode (frontend or worker)."""
|
|
112
|
+
os.environ["ENVIRONMENT"] = "production"
|
|
113
|
+
|
|
114
|
+
if service == "worker":
|
|
115
|
+
# Worker mode
|
|
116
|
+
from streaq.cli import main as streaq_main
|
|
117
|
+
|
|
118
|
+
worker_port = port if port else 11111
|
|
119
|
+
console.print(
|
|
120
|
+
f"[green]Starting worker in prod mode on port {worker_port}[/green]"
|
|
121
|
+
)
|
|
122
|
+
console.print(f"[dim]Workers: {workers_count}[/dim]")
|
|
123
|
+
|
|
124
|
+
# Call streaq programmatically
|
|
125
|
+
streaq_main(
|
|
126
|
+
worker_path="vibetuner.tasks.worker.worker",
|
|
127
|
+
workers=workers_count,
|
|
128
|
+
reload=False,
|
|
129
|
+
verbose=False,
|
|
130
|
+
web=True,
|
|
131
|
+
host="0.0.0.0", # noqa: S104
|
|
132
|
+
port=worker_port,
|
|
133
|
+
)
|
|
134
|
+
elif service == "frontend":
|
|
135
|
+
# Frontend mode
|
|
136
|
+
from granian import Granian
|
|
137
|
+
from granian.constants import Interfaces
|
|
138
|
+
|
|
139
|
+
frontend_port = port if port else 8000
|
|
140
|
+
console.print(
|
|
141
|
+
f"[green]Starting frontend in prod mode on {host}:{frontend_port}[/green]"
|
|
142
|
+
)
|
|
143
|
+
console.print(f"[dim]Workers: {workers_count}[/dim]")
|
|
144
|
+
|
|
145
|
+
server = Granian(
|
|
146
|
+
target="vibetuner.frontend:app",
|
|
147
|
+
address=host,
|
|
148
|
+
port=frontend_port,
|
|
149
|
+
interface=Interfaces.ASGI,
|
|
150
|
+
workers=workers_count,
|
|
151
|
+
reload=False,
|
|
152
|
+
log_level="info",
|
|
153
|
+
log_access=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
server.serve()
|
|
157
|
+
else:
|
|
158
|
+
console.print(f"[red]Error: Unknown service '{service}'[/red]")
|
|
159
|
+
console.print("[yellow]Valid services: 'frontend' or 'worker'[/yellow]")
|
|
160
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# ABOUTME: Scaffolding commands for creating new projects from the vibetuner template
|
|
2
|
+
# ABOUTME: Uses Copier to generate FastAPI+MongoDB+HTMX projects with interactive prompts
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import copier
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
scaffold_app = typer.Typer(
|
|
14
|
+
help="Create new projects from the vibetuner template", no_args_is_help=True
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@scaffold_app.command(name="new")
|
|
19
|
+
def new(
|
|
20
|
+
destination: Annotated[
|
|
21
|
+
Path,
|
|
22
|
+
typer.Argument(
|
|
23
|
+
help="Destination directory for the new project",
|
|
24
|
+
exists=False,
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
defaults: Annotated[
|
|
28
|
+
bool,
|
|
29
|
+
typer.Option(
|
|
30
|
+
"--defaults",
|
|
31
|
+
"-d",
|
|
32
|
+
help="Use default values for all prompts (non-interactive mode)",
|
|
33
|
+
),
|
|
34
|
+
] = False,
|
|
35
|
+
data: Annotated[
|
|
36
|
+
list[str] | None,
|
|
37
|
+
typer.Option(
|
|
38
|
+
"--data",
|
|
39
|
+
help="Override template variables in key=value format (can be used multiple times)",
|
|
40
|
+
),
|
|
41
|
+
] = None,
|
|
42
|
+
branch: Annotated[
|
|
43
|
+
str | None,
|
|
44
|
+
typer.Option(
|
|
45
|
+
"--branch",
|
|
46
|
+
"-b",
|
|
47
|
+
help="Use specific branch/tag from the vibetuner template repository",
|
|
48
|
+
),
|
|
49
|
+
] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Create a new project from the vibetuner template.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
|
|
55
|
+
# Interactive mode (prompts for all values)
|
|
56
|
+
vibetuner scaffold new my-project
|
|
57
|
+
|
|
58
|
+
# Use defaults for all prompts
|
|
59
|
+
vibetuner scaffold new my-project --defaults
|
|
60
|
+
|
|
61
|
+
# Override specific values
|
|
62
|
+
vibetuner scaffold new my-project --data project_name="My App" --data python_version="3.13"
|
|
63
|
+
|
|
64
|
+
# Use specific branch for testing
|
|
65
|
+
vibetuner scaffold new my-project --branch fix/scaffold-command
|
|
66
|
+
"""
|
|
67
|
+
# Use the official vibetuner template from GitHub
|
|
68
|
+
template_src = "gh:alltuner/vibetuner"
|
|
69
|
+
vcs_ref = branch or "main" # Use specified branch or default to main
|
|
70
|
+
|
|
71
|
+
if branch:
|
|
72
|
+
console.print(
|
|
73
|
+
f"[dim]Using vibetuner template from GitHub ({branch} branch)[/dim]"
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
console.print("[dim]Using vibetuner template from GitHub (main branch)[/dim]")
|
|
77
|
+
|
|
78
|
+
# Parse data overrides
|
|
79
|
+
data_dict = {}
|
|
80
|
+
if data:
|
|
81
|
+
for item in data:
|
|
82
|
+
if "=" not in item:
|
|
83
|
+
console.print(
|
|
84
|
+
f"[red]Error: Invalid data format '{item}'. Expected key=value[/red]"
|
|
85
|
+
)
|
|
86
|
+
raise typer.Exit(code=1)
|
|
87
|
+
key, value = item.split("=", 1)
|
|
88
|
+
data_dict[key] = value
|
|
89
|
+
|
|
90
|
+
# When using defaults, provide sensible default values for required fields
|
|
91
|
+
if defaults:
|
|
92
|
+
default_values = {
|
|
93
|
+
"company_name": "Acme Corp",
|
|
94
|
+
"author_name": "Developer",
|
|
95
|
+
"author_email": "dev@example.com",
|
|
96
|
+
"supported_languages": [],
|
|
97
|
+
}
|
|
98
|
+
# Merge: user overrides take precedence over defaults
|
|
99
|
+
data_dict = {**default_values, **data_dict}
|
|
100
|
+
|
|
101
|
+
# Run copier
|
|
102
|
+
try:
|
|
103
|
+
console.print(f"\n[green]Creating new project in: {destination}[/green]\n")
|
|
104
|
+
|
|
105
|
+
copier.run_copy(
|
|
106
|
+
src_path=str(template_src),
|
|
107
|
+
dst_path=destination,
|
|
108
|
+
data=data_dict if data_dict else None,
|
|
109
|
+
defaults=defaults,
|
|
110
|
+
quiet=defaults, # Suppress prompts when using defaults
|
|
111
|
+
unsafe=True, # Allow running post-generation tasks
|
|
112
|
+
vcs_ref=vcs_ref, # Use the specified branch or default to main
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
console.print("\n[green]✓ Project created successfully![/green]")
|
|
116
|
+
console.print("\nNext steps:")
|
|
117
|
+
console.print(f" cd {destination}")
|
|
118
|
+
console.print(" just dev")
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
console.print(f"[red]Error creating project: {e}[/red]")
|
|
122
|
+
raise typer.Exit(code=1) from None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@scaffold_app.command(name="update")
|
|
126
|
+
def update(
|
|
127
|
+
path: Annotated[
|
|
128
|
+
Path | None,
|
|
129
|
+
typer.Argument(
|
|
130
|
+
help="Path to the project to update",
|
|
131
|
+
),
|
|
132
|
+
] = None,
|
|
133
|
+
skip_answered: Annotated[
|
|
134
|
+
bool,
|
|
135
|
+
typer.Option(
|
|
136
|
+
"--skip-answered",
|
|
137
|
+
"-s",
|
|
138
|
+
help="Skip questions that have already been answered",
|
|
139
|
+
),
|
|
140
|
+
] = True,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Update an existing project to the latest template version.
|
|
143
|
+
|
|
144
|
+
This will update the project's files to match the latest template version,
|
|
145
|
+
while preserving your answers to the original questions.
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
|
|
149
|
+
# Update current directory
|
|
150
|
+
vibetuner scaffold update
|
|
151
|
+
|
|
152
|
+
# Update specific directory
|
|
153
|
+
vibetuner scaffold update /path/to/project
|
|
154
|
+
|
|
155
|
+
# Re-prompt for all questions
|
|
156
|
+
vibetuner scaffold update --no-skip-answered
|
|
157
|
+
"""
|
|
158
|
+
if path is None:
|
|
159
|
+
path = Path.cwd()
|
|
160
|
+
|
|
161
|
+
if not path.exists():
|
|
162
|
+
console.print(f"[red]Error: Directory does not exist: {path}[/red]")
|
|
163
|
+
raise typer.Exit(code=1)
|
|
164
|
+
|
|
165
|
+
# Check if it's a copier project
|
|
166
|
+
answers_file = path / ".copier-answers.yml"
|
|
167
|
+
if not answers_file.exists():
|
|
168
|
+
console.print(
|
|
169
|
+
"[red]Error: Not a copier project (missing .copier-answers.yml)[/red]"
|
|
170
|
+
)
|
|
171
|
+
console.print(f"[yellow]Directory: {path}[/yellow]")
|
|
172
|
+
raise typer.Exit(code=1)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
console.print(f"\n[green]Updating project: {path}[/green]\n")
|
|
176
|
+
|
|
177
|
+
copier.run_update(
|
|
178
|
+
dst_path=path,
|
|
179
|
+
skip_answered=skip_answered,
|
|
180
|
+
unsafe=True, # Allow running post-generation tasks
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
console.print("\n[green]✓ Project updated successfully![/green]")
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
console.print(f"[red]Error updating project: {e}[/red]")
|
|
187
|
+
raise typer.Exit(code=1) from None
|
vibetuner/config.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import (
|
|
9
|
+
UUID4,
|
|
10
|
+
Field,
|
|
11
|
+
HttpUrl,
|
|
12
|
+
MongoDsn,
|
|
13
|
+
RedisDsn,
|
|
14
|
+
SecretStr,
|
|
15
|
+
computed_field,
|
|
16
|
+
)
|
|
17
|
+
from pydantic_extra_types.language_code import LanguageAlpha2
|
|
18
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
19
|
+
|
|
20
|
+
from vibetuner.logging import logger
|
|
21
|
+
|
|
22
|
+
from .paths import config_vars as config_vars_path
|
|
23
|
+
from .versioning import version
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
current_year: int = datetime.now().year
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_project_config() -> "ProjectConfiguration":
|
|
30
|
+
if config_vars_path is None:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"Project root not detected. Cannot load project configuration. "
|
|
33
|
+
"Ensure you're running from within a project directory with .copier-answers.yml"
|
|
34
|
+
)
|
|
35
|
+
if not config_vars_path.exists():
|
|
36
|
+
return ProjectConfiguration()
|
|
37
|
+
|
|
38
|
+
yaml_data = yaml.safe_load(config_vars_path.read_text(encoding="utf-8"))
|
|
39
|
+
return ProjectConfiguration(**yaml_data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProjectConfiguration(BaseSettings):
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_project_config(cls) -> "ProjectConfiguration":
|
|
45
|
+
return _load_project_config()
|
|
46
|
+
|
|
47
|
+
project_slug: str = "default_project"
|
|
48
|
+
project_name: str = "default_project"
|
|
49
|
+
|
|
50
|
+
project_description: str = "A default project description."
|
|
51
|
+
|
|
52
|
+
# Language Related Settings
|
|
53
|
+
supported_languages: set[LanguageAlpha2] | None = None
|
|
54
|
+
default_language: LanguageAlpha2 = LanguageAlpha2("en")
|
|
55
|
+
|
|
56
|
+
# AWS Parameters
|
|
57
|
+
aws_default_region: str = "eu-central-1"
|
|
58
|
+
|
|
59
|
+
# Company Name
|
|
60
|
+
company_name: str = "Acme Corp"
|
|
61
|
+
|
|
62
|
+
# From Email for transactional emails
|
|
63
|
+
from_email: str = "no-reply@example.com"
|
|
64
|
+
|
|
65
|
+
# Copyright
|
|
66
|
+
copyright_start: Annotated[int, Field(strict=True, gt=1714, lt=2048)] = current_year
|
|
67
|
+
|
|
68
|
+
# Analytics
|
|
69
|
+
umami_website_id: UUID4 | None = None
|
|
70
|
+
|
|
71
|
+
# Fully Qualified Domain Name
|
|
72
|
+
fqdn: str | None = None
|
|
73
|
+
|
|
74
|
+
@cached_property
|
|
75
|
+
def languages(self) -> set[str]:
|
|
76
|
+
if self.supported_languages is None:
|
|
77
|
+
return {self.language}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
str(lang) for lang in (*self.supported_languages, self.default_language)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@cached_property
|
|
84
|
+
def language(self) -> str:
|
|
85
|
+
return str(self.default_language)
|
|
86
|
+
|
|
87
|
+
@cached_property
|
|
88
|
+
def copyright(self) -> str:
|
|
89
|
+
year_part = (
|
|
90
|
+
f"{self.copyright_start}-{current_year}"
|
|
91
|
+
if self.copyright_start and self.copyright_start != current_year
|
|
92
|
+
else str(current_year)
|
|
93
|
+
)
|
|
94
|
+
return f"© {year_part}{f' {self.company_name}' if self.company_name else ''}"
|
|
95
|
+
|
|
96
|
+
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CoreConfiguration(BaseSettings):
|
|
100
|
+
project: ProjectConfiguration = ProjectConfiguration.from_project_config()
|
|
101
|
+
|
|
102
|
+
debug: bool = False
|
|
103
|
+
version: str = version
|
|
104
|
+
session_key: SecretStr = SecretStr("ct-!secret-must-change-me")
|
|
105
|
+
|
|
106
|
+
# Database and Cache URLs
|
|
107
|
+
mongodb_url: MongoDsn = MongoDsn("mongodb://localhost:27017")
|
|
108
|
+
redis_url: RedisDsn = RedisDsn("redis://localhost:6379")
|
|
109
|
+
|
|
110
|
+
aws_access_key_id: SecretStr | None = None
|
|
111
|
+
aws_secret_access_key: SecretStr | None = None
|
|
112
|
+
|
|
113
|
+
r2_default_bucket_name: str | None = None
|
|
114
|
+
r2_bucket_endpoint_url: HttpUrl | None = None
|
|
115
|
+
r2_access_key: SecretStr | None = None
|
|
116
|
+
r2_secret_key: SecretStr | None = None
|
|
117
|
+
r2_default_region: str = "auto"
|
|
118
|
+
|
|
119
|
+
@computed_field
|
|
120
|
+
@cached_property
|
|
121
|
+
def v_hash(self) -> str:
|
|
122
|
+
hash_object = hashlib.sha256(self.version.encode("utf-8"))
|
|
123
|
+
hash_bytes = hash_object.digest()
|
|
124
|
+
|
|
125
|
+
b64_hash = base64.urlsafe_b64encode(hash_bytes).decode("utf-8")
|
|
126
|
+
|
|
127
|
+
url_safe_hash = b64_hash.rstrip("=")[:8]
|
|
128
|
+
|
|
129
|
+
return url_safe_hash
|
|
130
|
+
|
|
131
|
+
@cached_property
|
|
132
|
+
def mongo_dbname(self) -> str:
|
|
133
|
+
return self.project.project_slug
|
|
134
|
+
|
|
135
|
+
model_config = SettingsConfigDict(
|
|
136
|
+
case_sensitive=False, extra="ignore", env_file=".env"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
settings = CoreConfiguration()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
logger.info("Configuration loaded for project: {}", settings.project.project_name)
|
vibetuner/context.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pydantic import UUID4, BaseModel
|
|
2
|
+
|
|
3
|
+
from vibetuner.config import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Context(BaseModel):
|
|
7
|
+
DEBUG: bool = settings.debug
|
|
8
|
+
|
|
9
|
+
project_name: str = settings.project.project_name
|
|
10
|
+
project_slug: str = settings.project.project_slug
|
|
11
|
+
project_description: str = settings.project.project_description
|
|
12
|
+
|
|
13
|
+
version: str = settings.version
|
|
14
|
+
v_hash: str = settings.v_hash
|
|
15
|
+
|
|
16
|
+
copyright: str = settings.project.copyright
|
|
17
|
+
|
|
18
|
+
default_language: str = settings.project.language
|
|
19
|
+
supported_languages: set[str] = settings.project.languages
|
|
20
|
+
|
|
21
|
+
umami_website_id: UUID4 | None = settings.project.umami_website_id
|
|
22
|
+
|
|
23
|
+
fqdn: str | None = settings.project.fqdn
|
|
24
|
+
|
|
25
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ctx = Context()
|