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.
Files changed (106) hide show
  1. create_caspian_app/__init__.py +5 -0
  2. create_caspian_app/__main__.py +5 -0
  3. create_caspian_app/casp.py +74 -0
  4. create_caspian_app/cli.py +192 -0
  5. create_caspian_app/envfile.py +185 -0
  6. create_caspian_app/features.py +87 -0
  7. create_caspian_app/installer.py +106 -0
  8. create_caspian_app/scaffold.py +319 -0
  9. create_caspian_app/template/.claude/settings.json +3 -0
  10. create_caspian_app/template/.github/copilot-instructions.md +169 -0
  11. create_caspian_app/template/.github/instructions/database-generation.instructions.md +47 -0
  12. create_caspian_app/template/.github/instructions/tailwind-build.instructions.md +20 -0
  13. create_caspian_app/template/.python-version +1 -0
  14. create_caspian_app/template/AGENTS.md +455 -0
  15. create_caspian_app/template/CLAUDE.md +1 -0
  16. create_caspian_app/template/README.md +63 -0
  17. create_caspian_app/template/_gitignore +32 -0
  18. create_caspian_app/template/docs/README.md +24 -0
  19. create_caspian_app/template/docs/caspian/agent-development.md +22 -0
  20. create_caspian_app/template/docs/caspian/ai-validation-checklist.md +116 -0
  21. create_caspian_app/template/docs/caspian/auth.md +793 -0
  22. create_caspian_app/template/docs/caspian/authorization.md +127 -0
  23. create_caspian_app/template/docs/caspian/background-jobs.md +139 -0
  24. create_caspian_app/template/docs/caspian/cache.md +71 -0
  25. create_caspian_app/template/docs/caspian/commands.md +86 -0
  26. create_caspian_app/template/docs/caspian/components.md +820 -0
  27. create_caspian_app/template/docs/caspian/core-runtime-map.md +192 -0
  28. create_caspian_app/template/docs/caspian/database.md +427 -0
  29. create_caspian_app/template/docs/caspian/environment.md +205 -0
  30. create_caspian_app/template/docs/caspian/error-handling.md +127 -0
  31. create_caspian_app/template/docs/caspian/fetch-data.md +536 -0
  32. create_caspian_app/template/docs/caspian/file-conventions.md +315 -0
  33. create_caspian_app/template/docs/caspian/file-uploads.md +219 -0
  34. create_caspian_app/template/docs/caspian/i18n.md +134 -0
  35. create_caspian_app/template/docs/caspian/index.md +161 -0
  36. create_caspian_app/template/docs/caspian/installation.md +29 -0
  37. create_caspian_app/template/docs/caspian/maintenance.md +88 -0
  38. create_caspian_app/template/docs/caspian/mcp.md +60 -0
  39. create_caspian_app/template/docs/caspian/metadata.md +319 -0
  40. create_caspian_app/template/docs/caspian/observability.md +116 -0
  41. create_caspian_app/template/docs/caspian/prepared-pages.md +115 -0
  42. create_caspian_app/template/docs/caspian/project-structure.md +418 -0
  43. create_caspian_app/template/docs/caspian/pulsepoint-runtime-map.md +207 -0
  44. create_caspian_app/template/docs/caspian/pulsepoint.md +2019 -0
  45. create_caspian_app/template/docs/caspian/request-pipeline.md +108 -0
  46. create_caspian_app/template/docs/caspian/routing.md +597 -0
  47. create_caspian_app/template/docs/caspian/state.md +278 -0
  48. create_caspian_app/template/docs/caspian/static-export.md +112 -0
  49. create_caspian_app/template/docs/caspian/task-scheduling.md +80 -0
  50. create_caspian_app/template/docs/caspian/testing.md +324 -0
  51. create_caspian_app/template/docs/caspian/validation.md +370 -0
  52. create_caspian_app/template/docs/caspian/websockets.md +226 -0
  53. create_caspian_app/template/main.py +2198 -0
  54. create_caspian_app/template/prisma/migrations/20260930065337/migration.sql +31 -0
  55. create_caspian_app/template/prisma/migrations/migration_lock.toml +3 -0
  56. create_caspian_app/template/prisma/schema.prisma +26 -0
  57. create_caspian_app/template/prisma/seed.py +40 -0
  58. create_caspian_app/template/public/css/styles.css +964 -0
  59. create_caspian_app/template/public/favicon.ico +0 -0
  60. create_caspian_app/template/public/js/bundle-mjs.mjs.map +1 -0
  61. create_caspian_app/template/public/js/main.js +16 -0
  62. create_caspian_app/template/public/js/pp-reactive-v2.min.js +49 -0
  63. create_caspian_app/template/public/js/tailwind-merge.mjs +3368 -0
  64. create_caspian_app/template/pyproject.toml +73 -0
  65. create_caspian_app/template/settings/_component_imports.py +71 -0
  66. create_caspian_app/template/settings/_markup_equivalence.py +867 -0
  67. create_caspian_app/template/settings/browser_log.py +526 -0
  68. create_caspian_app/template/settings/build-static.py +307 -0
  69. create_caspian_app/template/settings/build.py +49 -0
  70. create_caspian_app/template/settings/build_pages.py +26 -0
  71. create_caspian_app/template/settings/check_templates.py +497 -0
  72. create_caspian_app/template/settings/dev_log_bridge.py +387 -0
  73. create_caspian_app/template/settings/discovery.py +58 -0
  74. create_caspian_app/template/settings/fix.py +113 -0
  75. create_caspian_app/template/settings/format.py +719 -0
  76. create_caspian_app/template/settings/page_cache.py +62 -0
  77. create_caspian_app/template/settings/prepare.py +195 -0
  78. create_caspian_app/template/settings/prepared_pages.py +360 -0
  79. create_caspian_app/template/settings/python_server.py +159 -0
  80. create_caspian_app/template/settings/serve-static.py +187 -0
  81. create_caspian_app/template/settings/serve_mcp.py +30 -0
  82. create_caspian_app/template/settings/tailwind-tool.json +13 -0
  83. create_caspian_app/template/settings/tailwind.py +298 -0
  84. create_caspian_app/template/settings/test.py +419 -0
  85. create_caspian_app/template/src/app/error.py +94 -0
  86. create_caspian_app/template/src/app/globals.css +34 -0
  87. create_caspian_app/template/src/app/index.py +99 -0
  88. create_caspian_app/template/src/app/layout.py +43 -0
  89. create_caspian_app/template/src/app/not_found.py +28 -0
  90. create_caspian_app/template/src/lib/auth/auth_config.py +46 -0
  91. create_caspian_app/template/src/lib/mcp/fastmcp.json +15 -0
  92. create_caspian_app/template/src/lib/mcp/mcp_server.py +93 -0
  93. create_caspian_app/template/src/lib/prisma/__init__.py +43 -0
  94. create_caspian_app/template/src/lib/prisma/db.py +10289 -0
  95. create_caspian_app/template/src/lib/prisma/models.py +585 -0
  96. create_caspian_app/template/src/lib/websocket/sockets.py +873 -0
  97. create_caspian_app/template/tests/README.md +63 -0
  98. create_caspian_app/template/tests/conftest.py +156 -0
  99. create_caspian_app/template/tests/test_app.py +48 -0
  100. create_caspian_app/template/uv.lock +1896 -0
  101. create_caspian_app/ui.py +101 -0
  102. create_caspian_app-0.0.1.dist-info/METADATA +116 -0
  103. create_caspian_app-0.0.1.dist-info/RECORD +106 -0
  104. create_caspian_app-0.0.1.dist-info/WHEEL +4 -0
  105. create_caspian_app-0.0.1.dist-info/entry_points.txt +3 -0
  106. create_caspian_app-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Scaffold Caspian Python applications."""
2
+
3
+ # One version for both the PyPI package and the Caspian framework: the
4
+ # installer writes it to every project's caspian.config.json.
5
+ __version__ = "0.0.1"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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)