create-caspian-app 0.0.1__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.
- create_caspian_app/__init__.py +5 -0
- create_caspian_app/__main__.py +5 -0
- create_caspian_app/casp.py +74 -0
- create_caspian_app/cli.py +192 -0
- create_caspian_app/envfile.py +185 -0
- create_caspian_app/features.py +87 -0
- create_caspian_app/installer.py +106 -0
- create_caspian_app/scaffold.py +319 -0
- create_caspian_app/template/.claude/settings.json +3 -0
- create_caspian_app/template/.github/copilot-instructions.md +169 -0
- create_caspian_app/template/.github/instructions/database-generation.instructions.md +47 -0
- create_caspian_app/template/.github/instructions/tailwind-build.instructions.md +20 -0
- create_caspian_app/template/.python-version +1 -0
- create_caspian_app/template/AGENTS.md +455 -0
- create_caspian_app/template/CLAUDE.md +1 -0
- create_caspian_app/template/README.md +63 -0
- create_caspian_app/template/_gitignore +32 -0
- create_caspian_app/template/docs/README.md +24 -0
- create_caspian_app/template/docs/caspian/agent-development.md +22 -0
- create_caspian_app/template/docs/caspian/ai-validation-checklist.md +116 -0
- create_caspian_app/template/docs/caspian/auth.md +793 -0
- create_caspian_app/template/docs/caspian/authorization.md +127 -0
- create_caspian_app/template/docs/caspian/background-jobs.md +139 -0
- create_caspian_app/template/docs/caspian/cache.md +71 -0
- create_caspian_app/template/docs/caspian/commands.md +86 -0
- create_caspian_app/template/docs/caspian/components.md +820 -0
- create_caspian_app/template/docs/caspian/core-runtime-map.md +192 -0
- create_caspian_app/template/docs/caspian/database.md +427 -0
- create_caspian_app/template/docs/caspian/environment.md +205 -0
- create_caspian_app/template/docs/caspian/error-handling.md +127 -0
- create_caspian_app/template/docs/caspian/fetch-data.md +536 -0
- create_caspian_app/template/docs/caspian/file-conventions.md +315 -0
- create_caspian_app/template/docs/caspian/file-uploads.md +219 -0
- create_caspian_app/template/docs/caspian/i18n.md +134 -0
- create_caspian_app/template/docs/caspian/index.md +161 -0
- create_caspian_app/template/docs/caspian/installation.md +29 -0
- create_caspian_app/template/docs/caspian/maintenance.md +88 -0
- create_caspian_app/template/docs/caspian/mcp.md +60 -0
- create_caspian_app/template/docs/caspian/metadata.md +319 -0
- create_caspian_app/template/docs/caspian/observability.md +116 -0
- create_caspian_app/template/docs/caspian/prepared-pages.md +115 -0
- create_caspian_app/template/docs/caspian/project-structure.md +418 -0
- create_caspian_app/template/docs/caspian/pulsepoint-runtime-map.md +207 -0
- create_caspian_app/template/docs/caspian/pulsepoint.md +2019 -0
- create_caspian_app/template/docs/caspian/request-pipeline.md +108 -0
- create_caspian_app/template/docs/caspian/routing.md +597 -0
- create_caspian_app/template/docs/caspian/state.md +278 -0
- create_caspian_app/template/docs/caspian/static-export.md +112 -0
- create_caspian_app/template/docs/caspian/task-scheduling.md +80 -0
- create_caspian_app/template/docs/caspian/testing.md +324 -0
- create_caspian_app/template/docs/caspian/validation.md +370 -0
- create_caspian_app/template/docs/caspian/websockets.md +226 -0
- create_caspian_app/template/main.py +2198 -0
- create_caspian_app/template/prisma/migrations/20260930065337/migration.sql +31 -0
- create_caspian_app/template/prisma/migrations/migration_lock.toml +3 -0
- create_caspian_app/template/prisma/schema.prisma +26 -0
- create_caspian_app/template/prisma/seed.py +40 -0
- create_caspian_app/template/public/css/styles.css +964 -0
- create_caspian_app/template/public/favicon.ico +0 -0
- create_caspian_app/template/public/js/bundle-mjs.mjs.map +1 -0
- create_caspian_app/template/public/js/main.js +16 -0
- create_caspian_app/template/public/js/pp-reactive-v2.min.js +49 -0
- create_caspian_app/template/public/js/tailwind-merge.mjs +3368 -0
- create_caspian_app/template/pyproject.toml +73 -0
- create_caspian_app/template/settings/_component_imports.py +71 -0
- create_caspian_app/template/settings/_markup_equivalence.py +867 -0
- create_caspian_app/template/settings/browser_log.py +526 -0
- create_caspian_app/template/settings/build-static.py +307 -0
- create_caspian_app/template/settings/build.py +49 -0
- create_caspian_app/template/settings/build_pages.py +26 -0
- create_caspian_app/template/settings/check_templates.py +497 -0
- create_caspian_app/template/settings/dev_log_bridge.py +387 -0
- create_caspian_app/template/settings/discovery.py +58 -0
- create_caspian_app/template/settings/fix.py +113 -0
- create_caspian_app/template/settings/format.py +719 -0
- create_caspian_app/template/settings/page_cache.py +62 -0
- create_caspian_app/template/settings/prepare.py +195 -0
- create_caspian_app/template/settings/prepared_pages.py +360 -0
- create_caspian_app/template/settings/python_server.py +159 -0
- create_caspian_app/template/settings/serve-static.py +187 -0
- create_caspian_app/template/settings/serve_mcp.py +30 -0
- create_caspian_app/template/settings/tailwind-tool.json +13 -0
- create_caspian_app/template/settings/tailwind.py +298 -0
- create_caspian_app/template/settings/test.py +419 -0
- create_caspian_app/template/src/app/error.py +94 -0
- create_caspian_app/template/src/app/globals.css +34 -0
- create_caspian_app/template/src/app/index.py +99 -0
- create_caspian_app/template/src/app/layout.py +43 -0
- create_caspian_app/template/src/app/not_found.py +28 -0
- create_caspian_app/template/src/lib/auth/auth_config.py +46 -0
- create_caspian_app/template/src/lib/mcp/fastmcp.json +15 -0
- create_caspian_app/template/src/lib/mcp/mcp_server.py +93 -0
- create_caspian_app/template/src/lib/prisma/__init__.py +43 -0
- create_caspian_app/template/src/lib/prisma/db.py +10289 -0
- create_caspian_app/template/src/lib/prisma/models.py +585 -0
- create_caspian_app/template/src/lib/websocket/sockets.py +873 -0
- create_caspian_app/template/tests/README.md +63 -0
- create_caspian_app/template/tests/conftest.py +156 -0
- create_caspian_app/template/tests/test_app.py +48 -0
- create_caspian_app/template/uv.lock +1896 -0
- create_caspian_app/ui.py +101 -0
- create_caspian_app-0.0.1.dist-info/METADATA +116 -0
- create_caspian_app-0.0.1.dist-info/RECORD +106 -0
- create_caspian_app-0.0.1.dist-info/WHEEL +4 -0
- create_caspian_app-0.0.1.dist-info/entry_points.txt +3 -0
- create_caspian_app-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""casp: project maintenance commands (``casp update project [version]``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import ui
|
|
12
|
+
from .cli import FLAG_NAMES
|
|
13
|
+
from .installer import InstallError, find_uv
|
|
14
|
+
|
|
15
|
+
USAGE = """Usage:
|
|
16
|
+
casp update project update to the latest create-caspian-app
|
|
17
|
+
casp update project 1.2.3 update to a specific version
|
|
18
|
+
casp update project -y update without prompts
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="casp", usage=USAGE)
|
|
24
|
+
parser.add_argument("command", nargs="*")
|
|
25
|
+
parser.add_argument("--tag", "--version", dest="version")
|
|
26
|
+
parser.add_argument("-y", "--yes", action="store_true")
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
words = args.command
|
|
30
|
+
if words[:2] != ["update", "project"] or len(words) > 3:
|
|
31
|
+
print(USAGE)
|
|
32
|
+
return 0 if not words else 1
|
|
33
|
+
|
|
34
|
+
version = args.version or (words[2] if len(words) == 3 else "latest")
|
|
35
|
+
version = version.lstrip("@v") if version != "latest" else version
|
|
36
|
+
|
|
37
|
+
config_path = Path.cwd() / "caspian.config.json"
|
|
38
|
+
if not config_path.is_file():
|
|
39
|
+
ui.error("caspian.config.json was not found in the current directory.")
|
|
40
|
+
return 1
|
|
41
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
42
|
+
|
|
43
|
+
if not args.yes and not ui_confirm(version):
|
|
44
|
+
ui.error("Operation cancelled by the user.")
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
uv = find_uv()
|
|
49
|
+
except InstallError as exc:
|
|
50
|
+
ui.error(str(exc))
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
command = uv + ["tool", "run", f"create-caspian-app@{version}", config["projectName"]]
|
|
54
|
+
command += [flag for feature, flag in FLAG_NAMES.items() if config.get(feature)]
|
|
55
|
+
if args.yes:
|
|
56
|
+
command.append("-y")
|
|
57
|
+
|
|
58
|
+
ui.info(f"\nUpdating with create-caspian-app@{version}\n")
|
|
59
|
+
return subprocess.run(command, check=False).returncode
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ui_confirm(version: str) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
return ui.ask_bool(
|
|
65
|
+
f"This updates the project with {ui.blue(f'create-caspian-app@{version}')} "
|
|
66
|
+
"and overwrites the default template files. Proceed?",
|
|
67
|
+
default=False,
|
|
68
|
+
)
|
|
69
|
+
except ui.Cancelled:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
sys.exit(main())
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""create-caspian-app: scaffold or update a Caspian Python application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, ui
|
|
11
|
+
from .features import FEATURES, LABELS, Answers
|
|
12
|
+
from .installer import InstallError, build_css, find_uv, sync, update_dependencies
|
|
13
|
+
from .scaffold import UpdateContext, remove_disabled, sync_stylesheet, write_project
|
|
14
|
+
|
|
15
|
+
FLAG_NAMES = {
|
|
16
|
+
"tailwindcss": "--tailwindcss",
|
|
17
|
+
"mcp": "--mcp",
|
|
18
|
+
"prisma": "--prisma",
|
|
19
|
+
"websocket": "--websocket",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="create-caspian-app",
|
|
26
|
+
description="Create a new Caspian app, or update the one in the current folder.",
|
|
27
|
+
epilog=(
|
|
28
|
+
"Examples:\n"
|
|
29
|
+
" uvx create-caspian-app my-app\n"
|
|
30
|
+
" uvx create-caspian-app my-app --tailwindcss --mcp -y\n"
|
|
31
|
+
" uvx create-caspian-app my-app --prisma --websocket -y"
|
|
32
|
+
),
|
|
33
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("project_name", nargs="?", help="folder to create (or update)")
|
|
36
|
+
for feature, flag in FLAG_NAMES.items():
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
flag, dest=feature, action="store_true", help=f"enable {LABELS[feature]}"
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-y", "--yes", action="store_true", help="no prompts: only the flags you pass are enabled"
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--skip-install",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="only write files; skip uv sync and the CSS build",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument("--version", action="version", version=f"create-caspian-app {__version__}")
|
|
49
|
+
return parser.parse_args(argv)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ask_features(answers: Answers, preset: set[str], interactive: bool) -> None:
|
|
53
|
+
for feature in FEATURES:
|
|
54
|
+
if feature in preset:
|
|
55
|
+
continue
|
|
56
|
+
if interactive:
|
|
57
|
+
setattr(
|
|
58
|
+
answers, feature, ui.ask_bool(f"Would you like to use {ui.blue(LABELS[feature])}?")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_existing_project(name: str | None) -> Path | None:
|
|
63
|
+
cwd = Path.cwd()
|
|
64
|
+
if name and (cwd / "caspian.config.json").is_file():
|
|
65
|
+
return cwd
|
|
66
|
+
if name and (cwd / name / "caspian.config.json").is_file():
|
|
67
|
+
return cwd / name
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create(args: argparse.Namespace) -> Path:
|
|
72
|
+
interactive = not args.yes
|
|
73
|
+
preset = {f for f in FEATURES if getattr(args, f)}
|
|
74
|
+
answers = Answers(**{f: getattr(args, f) for f in FEATURES})
|
|
75
|
+
|
|
76
|
+
name = args.project_name
|
|
77
|
+
if not name:
|
|
78
|
+
name = ui.ask_text("What is your project named?", "my-app") if interactive else "my-app"
|
|
79
|
+
name = name.strip().replace(" ", "-")
|
|
80
|
+
|
|
81
|
+
root = (Path.cwd() / name).resolve()
|
|
82
|
+
answers.project_name = root.name
|
|
83
|
+
if root.exists() and any(root.iterdir()):
|
|
84
|
+
raise SystemExit(
|
|
85
|
+
ui.red(f"The folder {root} already exists and is not empty. Pick another name.")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
ask_features(answers, preset, interactive)
|
|
89
|
+
|
|
90
|
+
print()
|
|
91
|
+
ui.info(f"Creating a new Caspian app in {ui.green(str(root))}")
|
|
92
|
+
ui.note("Features: " + (", ".join(answers.enabled()) or "none"))
|
|
93
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
write_project(root, answers)
|
|
95
|
+
ui.success("Project files written.")
|
|
96
|
+
|
|
97
|
+
if not args.skip_install:
|
|
98
|
+
uv = find_uv()
|
|
99
|
+
sync(root, uv)
|
|
100
|
+
build_css(root, answers, uv)
|
|
101
|
+
return root
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def update(args: argparse.Namespace, root: Path) -> Path:
|
|
105
|
+
interactive = not args.yes
|
|
106
|
+
config = json.loads((root / "caspian.config.json").read_text(encoding="utf-8"))
|
|
107
|
+
|
|
108
|
+
answers = Answers(project_name=config.get("projectName") or root.name)
|
|
109
|
+
for feature in FEATURES:
|
|
110
|
+
setattr(answers, feature, bool(config.get(feature)) or getattr(args, feature))
|
|
111
|
+
preset = {f for f in FEATURES if getattr(answers, f)}
|
|
112
|
+
|
|
113
|
+
ui.info(f"Updating the Caspian app in {ui.green(str(root))}")
|
|
114
|
+
ask_features(answers, preset, interactive)
|
|
115
|
+
|
|
116
|
+
if interactive:
|
|
117
|
+
ui.warn(
|
|
118
|
+
"\nThis refreshes the Caspian template files (main.py, settings/, src/, docs).\n"
|
|
119
|
+
"Your .env, pyproject.toml, uv.lock, src/app/globals.css, and tests/ are kept.\n"
|
|
120
|
+
'List any other file you customized in caspian.config.json "excludeFiles" too.'
|
|
121
|
+
)
|
|
122
|
+
if not ui.ask_bool("Continue?", default=False):
|
|
123
|
+
raise ui.Cancelled
|
|
124
|
+
|
|
125
|
+
context = UpdateContext(
|
|
126
|
+
exclude_files=list(config.get("excludeFiles") or []),
|
|
127
|
+
component_scan_dirs=list(config.get("componentScanDirs") or ["src"]),
|
|
128
|
+
)
|
|
129
|
+
write_project(root, answers, context)
|
|
130
|
+
for rel in remove_disabled(root, answers, context):
|
|
131
|
+
ui.note(f"Removed {rel} (feature disabled)")
|
|
132
|
+
sync_stylesheet(root, answers.tailwindcss)
|
|
133
|
+
ui.success("Project files updated.")
|
|
134
|
+
|
|
135
|
+
if not args.skip_install:
|
|
136
|
+
uv = find_uv()
|
|
137
|
+
update_dependencies(root, answers, uv)
|
|
138
|
+
sync(root, uv)
|
|
139
|
+
build_css(root, answers, uv)
|
|
140
|
+
return root
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def next_steps(root: Path, answers_config: dict, installed: bool, updated: bool) -> None:
|
|
144
|
+
cd = Path.cwd()
|
|
145
|
+
try:
|
|
146
|
+
shown = root.relative_to(cd)
|
|
147
|
+
except ValueError:
|
|
148
|
+
shown = root
|
|
149
|
+
print()
|
|
150
|
+
verb = "updated" if updated else "ready"
|
|
151
|
+
ui.success(f"Success! Your Caspian app is {verb} in {root.as_posix()}")
|
|
152
|
+
print("\nNext steps:\n")
|
|
153
|
+
if str(shown) != ".":
|
|
154
|
+
print(f" cd {shown}")
|
|
155
|
+
if not installed:
|
|
156
|
+
print(" uv sync --group dev")
|
|
157
|
+
if answers_config.get("tailwindcss"):
|
|
158
|
+
print(" uv run python settings/build.py setup")
|
|
159
|
+
print(" uv run python settings/build.py css")
|
|
160
|
+
print(" uv run python main.py")
|
|
161
|
+
if answers_config.get("prisma"):
|
|
162
|
+
print("\n Database: set DATABASE_URL in .env, then\n uv run caspian-db migrate deploy")
|
|
163
|
+
print(
|
|
164
|
+
"\n Tests: uv run python settings/test.py"
|
|
165
|
+
"\n Read README.md and AGENTS.md for the full workflow.\n"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> int:
|
|
170
|
+
args = parse_args(argv)
|
|
171
|
+
# Keep our messages ordered with the output of the uv child processes.
|
|
172
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
173
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
174
|
+
print(ui.bold(f"create-caspian-app {__version__}"))
|
|
175
|
+
try:
|
|
176
|
+
existing = find_existing_project(args.project_name)
|
|
177
|
+
root = update(args, existing) if existing else create(args)
|
|
178
|
+
except ui.Cancelled:
|
|
179
|
+
ui.error("Operation cancelled by the user.")
|
|
180
|
+
return 1
|
|
181
|
+
except InstallError as exc:
|
|
182
|
+
ui.error(f"\n{exc}")
|
|
183
|
+
ui.warn("The project files were written; fix the problem above and rerun the command.")
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
config = json.loads((root / "caspian.config.json").read_text(encoding="utf-8"))
|
|
187
|
+
next_steps(root, config, installed=not args.skip_install, updated=existing is not None)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
sys.exit(main())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Generate the .env for a new Caspian app, with fresh secrets.
|
|
2
|
+
|
|
3
|
+
The file lists only what a project must or commonly sets: credentials,
|
|
4
|
+
per-project values, security switches, and deployment origins. Every other
|
|
5
|
+
variable keeps the default of the code that reads it and is documented in
|
|
6
|
+
docs/caspian/environment.md, so it is left out here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import secrets
|
|
13
|
+
|
|
14
|
+
from .features import Answers
|
|
15
|
+
|
|
16
|
+
RULE = "# " + "=" * 77
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _section(title: str, body: str) -> str:
|
|
20
|
+
lines = [line if line.startswith("#") else f"# {line}" for line in title.split("\n")]
|
|
21
|
+
return f"{RULE}\n" + "\n".join(lines) + f"\n{RULE}\n\n{body.strip()}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_env(answers: Answers) -> str:
|
|
25
|
+
auth_secret = base64.b64encode(secrets.token_bytes(33)).decode()
|
|
26
|
+
cookie_name = secrets.token_hex(8)
|
|
27
|
+
mcp_token = secrets.token_hex(32)
|
|
28
|
+
|
|
29
|
+
sections: list[str] = []
|
|
30
|
+
|
|
31
|
+
if answers.prisma:
|
|
32
|
+
sections.append(
|
|
33
|
+
_section(
|
|
34
|
+
"DATABASE\n# Read by prisma/schema.prisma and src/lib/prisma/db.py.",
|
|
35
|
+
"""
|
|
36
|
+
# Connection string. A credential, which is why .env is git-ignored. The provider
|
|
37
|
+
# in prisma/schema.prisma must match the URL scheme (postgresql://, mysql://, or
|
|
38
|
+
# file: for SQLite). Format reference: https://pris.ly/d/connection-strings
|
|
39
|
+
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
|
|
40
|
+
""",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
sections.append(
|
|
45
|
+
_section(
|
|
46
|
+
"APPLICATION RUNTIME\n"
|
|
47
|
+
"# Read by casp/runtime_security.py is_production_environment() and casp/app_time.py.",
|
|
48
|
+
"""
|
|
49
|
+
# Resolved FAIL-CLOSED: only dev, development, local, staging, test or testing
|
|
50
|
+
# enable the development relaxations. Unset or misspelled counts as production,
|
|
51
|
+
# which turns on Secure cookies, HSTS, generic errors, a mandatory AUTH_SECRET,
|
|
52
|
+
# and the production checks of the MCP and WebSocket sections below.
|
|
53
|
+
APP_ENV="development"
|
|
54
|
+
|
|
55
|
+
# IANA timezone deciding which calendar day an instant belongs to. Timestamps
|
|
56
|
+
# stay stored in UTC; session expiry and cache TTLs are unaffected. Empty = UTC.
|
|
57
|
+
APP_TIMEZONE="UTC"
|
|
58
|
+
""",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
sections.append(
|
|
62
|
+
_section(
|
|
63
|
+
"PUBLIC URL AND ORIGINS\n# Read by casp/rpc.py origin checks and the main.py CORS layer.",
|
|
64
|
+
"""
|
|
65
|
+
# Canonical public origin. Leave empty when the browser URL and the app URL
|
|
66
|
+
# match; set it behind a reverse proxy, load balancer or TLS terminator.
|
|
67
|
+
APP_BASE_URL=""
|
|
68
|
+
|
|
69
|
+
# Extra browser origins allowed to call RPC. Comma-separated, no spaces.
|
|
70
|
+
CORS_ALLOWED_ORIGINS=""
|
|
71
|
+
|
|
72
|
+
# Trust Forwarded/X-Forwarded-* headers for the RPC origin check and the rate
|
|
73
|
+
# limiter address. Enable ONLY when a proxy strips client-supplied copies.
|
|
74
|
+
TRUST_FORWARDED_HEADERS="false"
|
|
75
|
+
""",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
sections.append(
|
|
79
|
+
_section(
|
|
80
|
+
"AUTHENTICATION AND SESSIONS\n# Read by casp/auth.py and main.py SessionMiddleware. "
|
|
81
|
+
"Route privacy and RBAC\n# live in src/lib/auth/auth_config.py, not here.",
|
|
82
|
+
f"""
|
|
83
|
+
# Session signing secret, unique per app and environment. Production refuses to
|
|
84
|
+
# start when it is missing or a placeholder ("change-me"/"changeme").
|
|
85
|
+
AUTH_SECRET="{auth_secret}"
|
|
86
|
+
|
|
87
|
+
# Unique per project: apps sharing a parent domain and cookie name overwrite
|
|
88
|
+
# each other's sessions.
|
|
89
|
+
AUTH_COOKIE_NAME="{cookie_name}"
|
|
90
|
+
|
|
91
|
+
# Session lifetime in hours. Default 7.
|
|
92
|
+
SESSION_LIFETIME_HOURS="7"
|
|
93
|
+
""",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
sections.append(
|
|
97
|
+
_section(
|
|
98
|
+
"OAUTH PROVIDERS\n# AuthMiddleware already serves /api/auth/signin/{google,github} and\n"
|
|
99
|
+
"# /api/auth/callback/{google,github}. A provider with no client id is skipped\n"
|
|
100
|
+
"# silently (the button appears dead). Empty means disabled.",
|
|
101
|
+
"""
|
|
102
|
+
GOOGLE_CLIENT_ID=
|
|
103
|
+
GOOGLE_CLIENT_SECRET=
|
|
104
|
+
# Must match the Google Cloud Console redirect URI exactly; required for Google.
|
|
105
|
+
GOOGLE_REDIRECT_URI=
|
|
106
|
+
|
|
107
|
+
GITHUB_CLIENT_ID=
|
|
108
|
+
GITHUB_CLIENT_SECRET=
|
|
109
|
+
# Must match a URI registered on the GitHub App. Empty derives it from
|
|
110
|
+
# APP_BASE_URL as "<APP_BASE_URL>/api/auth/callback/github".
|
|
111
|
+
GITHUB_REDIRECT_URI=
|
|
112
|
+
""",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
sections.append(
|
|
116
|
+
_section(
|
|
117
|
+
"REQUEST SECURITY\n# Read by main.py BodySizeLimitMiddleware and casp/runtime_security.py.",
|
|
118
|
+
"""
|
|
119
|
+
# Max whole request body in MB (file + fields + encoding). Default 16.
|
|
120
|
+
MAX_CONTENT_LENGTH_MB="16"
|
|
121
|
+
|
|
122
|
+
# Replaces the built-in Content-Security-Policy wholesale; empty keeps the
|
|
123
|
+
# default. A replacement MUST keep 'unsafe-eval' and 'unsafe-inline' in
|
|
124
|
+
# script-src (PulsePoint compiles templates with new Function()). An override
|
|
125
|
+
# also replaces the development-only loopback connect-src entries.
|
|
126
|
+
CONTENT_SECURITY_POLICY=
|
|
127
|
+
""",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if answers.websocket:
|
|
132
|
+
sections.append(
|
|
133
|
+
_section(
|
|
134
|
+
"WEBSOCKETS\n# Read by src/lib/websocket/sockets.py (named-socket endpoint).",
|
|
135
|
+
"""
|
|
136
|
+
# Browser origins allowed to open a socket. Falls back to CORS_ALLOWED_ORIGINS,
|
|
137
|
+
# then APP_BASE_URL. REQUIRED IN PRODUCTION: the same-origin fallback trusts the
|
|
138
|
+
# client Host header and is development-only. Comma-separated, no spaces.
|
|
139
|
+
WEBSOCKET_ALLOWED_ORIGINS=
|
|
140
|
+
""",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Always written, even when MCP is off, so enabling mcp in
|
|
145
|
+
# caspian.config.json later finds a token already in place.
|
|
146
|
+
sections.append(
|
|
147
|
+
_section(
|
|
148
|
+
"MCP ENDPOINT\n# Read by main.py MCPAuthMiddleware. /mcp is mounted outside the routing tree,\n"
|
|
149
|
+
"# so AuthMiddleware does not protect it. Required in production. Inert until\n"
|
|
150
|
+
"# caspian.config.json has mcp: true.",
|
|
151
|
+
f"""
|
|
152
|
+
MCP_AUTH_TOKEN="{mcp_token}"
|
|
153
|
+
""",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
sections.append(
|
|
157
|
+
_section(
|
|
158
|
+
"DATA CACHE AND JOBS\n# Read by casp/cache.py and casp/jobs.py.",
|
|
159
|
+
"""
|
|
160
|
+
# Data cache store: memory, file, or redis (redis needs `uv add redis`).
|
|
161
|
+
CACHE_STORE="memory"
|
|
162
|
+
# Redis connection for CACHE_STORE=redis, e.g. redis://localhost:6379/0.
|
|
163
|
+
CACHE_REDIS_URL=
|
|
164
|
+
|
|
165
|
+
# Job queue: memory (lost on restart) or sqlite (survives restarts).
|
|
166
|
+
JOBS_DRIVER="memory"
|
|
167
|
+
""",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
sections.append(
|
|
171
|
+
_section(
|
|
172
|
+
"SERVER PROCESS\n# Read by main.py __main__.",
|
|
173
|
+
"""
|
|
174
|
+
# Uvicorn worker processes. Keep 1 unless the app is built for multi-process
|
|
175
|
+
# coordination; workers duplicate memory, pools and in-process state.
|
|
176
|
+
UVICORN_WORKERS="1"
|
|
177
|
+
|
|
178
|
+
# Every other setting (rate limits, websocket limits, logging, maintenance,
|
|
179
|
+
# job tuning, localization, Prisma pool, CORS preflight) has a built-in default.
|
|
180
|
+
# See docs/caspian/environment.md before adding one here.
|
|
181
|
+
""",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return "\n\n".join(sections) + "\n"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The optional features a Caspian app can be generated with, and what each one owns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
FEATURES = ("tailwindcss", "mcp", "prisma", "websocket")
|
|
8
|
+
|
|
9
|
+
LABELS = {
|
|
10
|
+
"tailwindcss": "Tailwind CSS",
|
|
11
|
+
"mcp": "MCP (Model Context Protocol)",
|
|
12
|
+
"prisma": "Prisma ORM (Caspian DB)",
|
|
13
|
+
"websocket": "WebSocket",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Answers:
|
|
19
|
+
project_name: str = "my-app"
|
|
20
|
+
tailwindcss: bool = False
|
|
21
|
+
mcp: bool = False
|
|
22
|
+
prisma: bool = False
|
|
23
|
+
websocket: bool = False
|
|
24
|
+
|
|
25
|
+
def enabled(self) -> list[str]:
|
|
26
|
+
return [name for name in FEATURES if getattr(self, name)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Python requirement names owned by each feature. The pinned specifiers come
|
|
30
|
+
# from the template's pyproject.toml, so a template sync is the only place
|
|
31
|
+
# versions change.
|
|
32
|
+
FEATURE_DEPENDENCIES = {
|
|
33
|
+
"mcp": ("fastmcp",),
|
|
34
|
+
"websocket": ("websockets",),
|
|
35
|
+
"prisma": ("psycopg2-binary", "asyncpg", "aiosqlite", "aiomysql", "cryptography"),
|
|
36
|
+
}
|
|
37
|
+
FEATURE_DEV_DEPENDENCIES = {
|
|
38
|
+
"prisma": ("caspian-db",),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Template paths (POSIX, relative to the project root) that exist only for a
|
|
42
|
+
# feature. A trailing slash marks a directory.
|
|
43
|
+
FEATURE_PATHS = {
|
|
44
|
+
"mcp": ("src/lib/mcp/", "settings/serve_mcp.py"),
|
|
45
|
+
"websocket": ("src/lib/websocket/",),
|
|
46
|
+
"prisma": (
|
|
47
|
+
"prisma/",
|
|
48
|
+
"src/lib/prisma/",
|
|
49
|
+
),
|
|
50
|
+
"tailwindcss": ("public/js/tailwind-merge.mjs", "public/js/bundle-mjs.mjs.map"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# The package main.py imports lazily for each feature.
|
|
54
|
+
FEATURE_MODULES = {
|
|
55
|
+
"mcp": "src.lib.mcp",
|
|
56
|
+
"websocket": "src.lib.websocket",
|
|
57
|
+
"prisma": "src.lib.prisma",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def owned_by(path: str, prefixes: tuple[str, ...]) -> bool:
|
|
62
|
+
return any(path == p or (p.endswith("/") and path.startswith(p)) for p in prefixes)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def excluded_paths(answers: Answers) -> tuple[str, ...]:
|
|
66
|
+
"""Every template path that must not exist for these answers."""
|
|
67
|
+
paths: list[str] = []
|
|
68
|
+
for feature, owned in FEATURE_PATHS.items():
|
|
69
|
+
if not getattr(answers, feature):
|
|
70
|
+
paths.extend(owned)
|
|
71
|
+
return tuple(paths)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def disabled_modules(answers: Answers) -> list[str]:
|
|
75
|
+
return [module for feature, module in FEATURE_MODULES.items() if not getattr(answers, feature)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def disabled_dependencies(answers: Answers) -> tuple[set[str], set[str]]:
|
|
79
|
+
deps: set[str] = set()
|
|
80
|
+
dev: set[str] = set()
|
|
81
|
+
for feature, names in FEATURE_DEPENDENCIES.items():
|
|
82
|
+
if not getattr(answers, feature):
|
|
83
|
+
deps.update(names)
|
|
84
|
+
for feature, names in FEATURE_DEV_DEPENDENCIES.items():
|
|
85
|
+
if not getattr(answers, feature):
|
|
86
|
+
dev.update(names)
|
|
87
|
+
return deps, dev
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Run uv to create the environment and finish the build of a scaffolded app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import ui
|
|
12
|
+
from .features import Answers, disabled_dependencies
|
|
13
|
+
from .scaffold import requirement_name, template_pyproject
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InstallError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _works(command: list[str]) -> bool:
|
|
21
|
+
try:
|
|
22
|
+
return (
|
|
23
|
+
subprocess.run(command + ["--version"], capture_output=True, check=False).returncode
|
|
24
|
+
== 0
|
|
25
|
+
)
|
|
26
|
+
except OSError:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_uv(allow_install: bool = True) -> list[str]:
|
|
31
|
+
"""Return the argv prefix that runs uv, installing it with pip as a last resort."""
|
|
32
|
+
candidates = []
|
|
33
|
+
if exe := shutil.which("uv"):
|
|
34
|
+
candidates.append([exe])
|
|
35
|
+
candidates.append([sys.executable, "-m", "uv"])
|
|
36
|
+
for candidate in candidates:
|
|
37
|
+
if _works(candidate):
|
|
38
|
+
return candidate
|
|
39
|
+
if allow_install:
|
|
40
|
+
ui.info("uv was not found. Installing it with pip (python -m pip install uv)...")
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "uv"], check=False
|
|
43
|
+
)
|
|
44
|
+
if result.returncode == 0 and _works([sys.executable, "-m", "uv"]):
|
|
45
|
+
return [sys.executable, "-m", "uv"]
|
|
46
|
+
raise InstallError(
|
|
47
|
+
"uv is required but could not be found or installed.\n"
|
|
48
|
+
"Install it from https://docs.astral.sh/uv/getting-started/installation/ and run again."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(argv: list[str], cwd: Path) -> None:
|
|
53
|
+
ui.note("$ " + " ".join(_display(a) for a in argv))
|
|
54
|
+
# A VIRTUAL_ENV from the shell that launched us would point uv at the wrong venv.
|
|
55
|
+
env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
|
|
56
|
+
result = subprocess.run(argv, cwd=cwd, env=env, check=False)
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
raise InstallError(f"Command failed with exit code {result.returncode}: {argv[-1]}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _display(arg: str) -> str:
|
|
62
|
+
return "uv" if arg.lower().endswith(("uv", "uv.exe")) and os.sep in arg else arg
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sync(root: Path, uv: list[str]) -> None:
|
|
66
|
+
ui.step("Installing Python dependencies (uv sync --group dev)")
|
|
67
|
+
run(uv + ["sync", "--group", "dev"], root)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def update_dependencies(root: Path, answers: Answers, uv: list[str]) -> None:
|
|
71
|
+
"""Re-pin managed requirements to the template and drop disabled-feature ones."""
|
|
72
|
+
import tomllib
|
|
73
|
+
|
|
74
|
+
current = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
75
|
+
template = template_pyproject()
|
|
76
|
+
removed, removed_dev = disabled_dependencies(answers)
|
|
77
|
+
|
|
78
|
+
have = {requirement_name(s) for s in current.get("project", {}).get("dependencies", [])}
|
|
79
|
+
have_dev = {
|
|
80
|
+
requirement_name(s)
|
|
81
|
+
for s in current.get("dependency-groups", {}).get("dev", [])
|
|
82
|
+
if isinstance(s, str)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
drop = sorted(have & removed)
|
|
86
|
+
drop_dev = sorted(have_dev & removed_dev)
|
|
87
|
+
add = [s for s in template["project"]["dependencies"] if requirement_name(s) not in removed]
|
|
88
|
+
add_dev = [
|
|
89
|
+
s for s in template["dependency-groups"]["dev"] if requirement_name(s) not in removed_dev
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
ui.step("Updating Python dependencies")
|
|
93
|
+
if drop:
|
|
94
|
+
run(uv + ["remove", "--no-sync", *drop], root)
|
|
95
|
+
if drop_dev:
|
|
96
|
+
run(uv + ["remove", "--no-sync", "--group", "dev", *drop_dev], root)
|
|
97
|
+
run(uv + ["add", "--no-sync", *add], root)
|
|
98
|
+
run(uv + ["add", "--no-sync", "--group", "dev", *add_dev], root)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_css(root: Path, answers: Answers, uv: list[str]) -> None:
|
|
102
|
+
if answers.tailwindcss:
|
|
103
|
+
ui.step("Downloading the pinned Tailwind CSS compiler (checksum verified)")
|
|
104
|
+
run(uv + ["run", "python", "settings/build.py", "setup"], root)
|
|
105
|
+
ui.step("Building CSS (src/app/globals.css -> public/css/styles.css)")
|
|
106
|
+
run(uv + ["run", "python", "settings/build.py", "css"], root)
|