stkit 17.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. stk/__init__.py +19 -0
  2. stk/__main__.py +6 -0
  3. stk/agent_login.py +78 -0
  4. stk/app.py +323 -0
  5. stk/checks.py +634 -0
  6. stk/cli/__init__.py +49 -0
  7. stk/cli/agent.py +267 -0
  8. stk/cli/base.py +79 -0
  9. stk/cli/database.py +199 -0
  10. stk/cli/develop.py +63 -0
  11. stk/cli/doctor.py +262 -0
  12. stk/cli/main.py +101 -0
  13. stk/cli/reports.py +296 -0
  14. stk/cli/smoke.py +399 -0
  15. stk/cli/users.py +259 -0
  16. stk/cli/watch.py +68 -0
  17. stk/extensions.py +29 -0
  18. stk/migrations/__init__.py +130 -0
  19. stk/migrations/env.py +58 -0
  20. stk/migrations/script.py.mako +17 -0
  21. stk/migrations/versions/stk_0001_initial_schema.py +213 -0
  22. stk/portal/views.py +34 -0
  23. stk/public/__init__.py +0 -0
  24. stk/public/models.py +0 -0
  25. stk/public/views.py +344 -0
  26. stk/scaffold/__init__.py +1 -0
  27. stk/scaffold/app/CLAUDE.md +65 -0
  28. stk/scaffold/app/Dockerfile +41 -0
  29. stk/scaffold/app/README.md +16 -0
  30. stk/scaffold/app/__app__/__init__.py +1 -0
  31. stk/scaffold/app/__app__/app.py +8 -0
  32. stk/scaffold/app/__app__/settings.py +7 -0
  33. stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
  34. stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
  35. stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
  36. stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
  37. stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
  38. stk/scaffold/app/docker-compose.yml +88 -0
  39. stk/scaffold/app/dockerignore +13 -0
  40. stk/scaffold/app/env-sample +78 -0
  41. stk/scaffold/app/gitignore +8 -0
  42. stk/scaffold/app/nginx/nginx.conf +46 -0
  43. stk/scaffold/app/nginx/stk.conf +36 -0
  44. stk/scaffold/app/postgres/init-db.sh +8 -0
  45. stk/scaffold/app/pyproject.toml +23 -0
  46. stk/scaffold/app/tests/__init__.py +0 -0
  47. stk/scaffold/app/tests/test_app.py +23 -0
  48. stk/scaffold/generator.py +209 -0
  49. stk/scaffold/templates.py +437 -0
  50. stk/security.py +145 -0
  51. stk/settings.py +147 -0
  52. stk/shell.py +284 -0
  53. stk/static/VERSIONS.txt +4 -0
  54. stk/static/css/app.css +229 -0
  55. stk/static/css/layout.css +330 -0
  56. stk/static/css/vuetify.min.css +6 -0
  57. stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  58. stk/static/icons/tabler-icons.min.css +4 -0
  59. stk/static/img/auth-bg.webp +0 -0
  60. stk/static/img/favicon.ico +0 -0
  61. stk/static/img/stk.svg +1664 -0
  62. stk/static/js/axios.min.js +5 -0
  63. stk/static/js/components/NotificationDropdown.js +156 -0
  64. stk/static/js/components/ThemeSwitcher.js +68 -0
  65. stk/static/js/components/TransitionExpand.js +64 -0
  66. stk/static/js/components/VerticalNavGroup.js +119 -0
  67. stk/static/js/components/VerticalNavLink.js +36 -0
  68. stk/static/js/components/VerticalNavSectionTitle.js +21 -0
  69. stk/static/js/components/index.js +47 -0
  70. stk/static/js/config.js +150 -0
  71. stk/static/js/vue.min.js +13 -0
  72. stk/static/js/vuetify.min.js +3148 -0
  73. stk/static/robots.txt +1 -0
  74. stk/tasks/__init__.py +78 -0
  75. stk/templates/401.html +14 -0
  76. stk/templates/404.html +12 -0
  77. stk/templates/500.html +13 -0
  78. stk/templates/_brand.html +1 -0
  79. stk/templates/auth_layout.html +26 -0
  80. stk/templates/cms/activities.html +135 -0
  81. stk/templates/cms/roles.html +215 -0
  82. stk/templates/cms/users.html +312 -0
  83. stk/templates/dashboard.html +132 -0
  84. stk/templates/index.html +52 -0
  85. stk/templates/layout.html +328 -0
  86. stk/templates/login-layout.html +54 -0
  87. stk/templates/security/_macros.html +39 -0
  88. stk/templates/security/_messages.html +12 -0
  89. stk/templates/security/change_password.html +100 -0
  90. stk/templates/security/confirm_email.html +44 -0
  91. stk/templates/security/email/change_notice.html +4 -0
  92. stk/templates/security/email/change_notice.txt +5 -0
  93. stk/templates/security/email/confirmation_instructions.html +3 -0
  94. stk/templates/security/email/confirmation_instructions.txt +3 -0
  95. stk/templates/security/email/login_instructions.html +5 -0
  96. stk/templates/security/email/login_instructions.txt +5 -0
  97. stk/templates/security/email/reset_instructions.html +1 -0
  98. stk/templates/security/email/reset_instructions.txt +3 -0
  99. stk/templates/security/email/reset_notice.html +1 -0
  100. stk/templates/security/email/reset_notice.txt +1 -0
  101. stk/templates/security/email/welcome.html +7 -0
  102. stk/templates/security/email/welcome.txt +7 -0
  103. stk/templates/security/forgot_password.html +63 -0
  104. stk/templates/security/login_user.html +206 -0
  105. stk/templates/security/mf_recovery.html +67 -0
  106. stk/templates/security/mf_recovery_codes.html +119 -0
  107. stk/templates/security/register_user.html +263 -0
  108. stk/templates/security/reset_password.html +53 -0
  109. stk/templates/security/send_confirmation.html +48 -0
  110. stk/templates/security/send_login.html +39 -0
  111. stk/templates/security/two_factor_select.html +78 -0
  112. stk/templates/security/two_factor_setup.html +166 -0
  113. stk/templates/security/two_factor_verify_code.html +71 -0
  114. stk/templates/security/wan_register.html +179 -0
  115. stk/templates/security/wan_signin.html +123 -0
  116. stk/templates/security/wan_verify.html +109 -0
  117. stk/user/__init__.py +0 -0
  118. stk/user/forms.py +10 -0
  119. stk/user/models.py +308 -0
  120. stk/user/views.py +339 -0
  121. stk/utils/email.py +32 -0
  122. stk/utils/ratelimit.py +67 -0
  123. stk/websocket.py +133 -0
  124. stkit-17.0.0.dist-info/METADATA +135 -0
  125. stkit-17.0.0.dist-info/RECORD +128 -0
  126. stkit-17.0.0.dist-info/WHEEL +4 -0
  127. stkit-17.0.0.dist-info/entry_points.txt +2 -0
  128. stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/public/views.py ADDED
@@ -0,0 +1,344 @@
1
+ import datetime
2
+ import secrets
3
+
4
+ import httpx
5
+ from authlib.integrations.httpx_client import AsyncOAuth2Client
6
+ from authlib.oidc.core import CodeIDToken
7
+ from joserfc import jwt
8
+ from joserfc.jwk import KeySet
9
+ from quart import (
10
+ Blueprint,
11
+ current_app,
12
+ flash,
13
+ g,
14
+ redirect,
15
+ render_template,
16
+ request,
17
+ send_from_directory,
18
+ session,
19
+ url_for,
20
+ )
21
+ from quart_security import current_user
22
+ from quart_security.proxies import _security
23
+ from sqlalchemy import select
24
+
25
+ from stk.user.models import OAuth, User
26
+
27
+ public = Blueprint("public", __name__, static_folder="../static")
28
+
29
+
30
+ def get_real_ip():
31
+ return request.remote_addr
32
+
33
+
34
+ def create_oauth_user(provider_data, ip_address):
35
+ now = datetime.datetime.now()
36
+ user = User(
37
+ email=provider_data.get("email"),
38
+ username=provider_data.get("email"),
39
+ name=provider_data.get("name", ""),
40
+ password=User.random_password(),
41
+ password_set=False,
42
+ active=True,
43
+ confirmed_at=now,
44
+ current_login_at=now,
45
+ current_login_ip=ip_address,
46
+ login_count=1,
47
+ )
48
+ return user
49
+
50
+
51
+ @public.route("/health")
52
+ async def health():
53
+ import stk.extensions as ext
54
+
55
+ status = {"status": "ok", "checks": {}}
56
+
57
+ # DB check
58
+ try:
59
+ from sqlalchemy import text
60
+
61
+ async with ext.engine.connect() as conn:
62
+ await conn.execute(text("SELECT 1"))
63
+ status["checks"]["database"] = "ok"
64
+ except Exception:
65
+ status["checks"]["database"] = "error"
66
+ status["status"] = "degraded"
67
+
68
+ # Redis check (if configured)
69
+ session_interface = getattr(current_app, "session_interface", None)
70
+ backend = getattr(session_interface, "backend", None)
71
+ if backend is not None:
72
+ try:
73
+ await backend.ping()
74
+ status["checks"]["redis"] = "ok"
75
+ except Exception:
76
+ status["checks"]["redis"] = "error"
77
+ status["status"] = "degraded"
78
+
79
+ code = 200 if status["status"] == "ok" else 503
80
+ return status, code
81
+
82
+
83
+ @public.route("/")
84
+ async def index():
85
+ return await render_template("index.html")
86
+
87
+
88
+ @public.route("/robots.txt")
89
+ async def static_from_root():
90
+ return await send_from_directory(public.static_folder, request.path[1:])
91
+
92
+
93
+ # OAuth transactions use single-use server state and PKCE.
94
+ GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration"
95
+ GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
96
+ GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
97
+ GITHUB_API_URL = "https://api.github.com"
98
+
99
+
100
+ async def authentication_failed():
101
+ await flash(
102
+ "Authentication failed. Use your existing sign-in method.", category="error"
103
+ )
104
+ return redirect(url_for("security.login"))
105
+
106
+
107
+ async def handle_oauth_callback(provider_name, token, user_info):
108
+ provider_user_id = (
109
+ user_info.get("sub") if provider_name == "google" else user_info.get("id")
110
+ )
111
+ email = user_info.get("email")
112
+ if (
113
+ not token
114
+ or not provider_user_id
115
+ or not email
116
+ or user_info.get("email_verified") is not True
117
+ ):
118
+ return await authentication_failed()
119
+ account = await g.db_session.scalar(
120
+ select(OAuth).where(
121
+ OAuth.provider == provider_name,
122
+ OAuth.provider_user_id == str(provider_user_id),
123
+ )
124
+ )
125
+ if account:
126
+ user = account.user
127
+ else:
128
+ # Email ownership at an IdP does not authorize linking local credentials.
129
+ existing = await g.db_session.scalar(select(User).where(User.email == email))
130
+ if existing:
131
+ return await authentication_failed()
132
+ user = create_oauth_user(
133
+ {
134
+ "email": email,
135
+ "name": user_info.get("name") or user_info.get("login", ""),
136
+ },
137
+ get_real_ip(),
138
+ )
139
+ g.db_session.add_all(
140
+ [
141
+ user,
142
+ OAuth(
143
+ provider=provider_name,
144
+ provider_user_id=str(provider_user_id),
145
+ user=user,
146
+ ),
147
+ ]
148
+ )
149
+ await g.db_session.commit()
150
+ if not user.active or (
151
+ user.locked_until
152
+ and user.locked_until > datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
153
+ ):
154
+ return await authentication_failed()
155
+ if current_user.is_authenticated:
156
+ await _security.logout_user()
157
+ if current_app.config.get("SECURITY_TWO_FACTOR") and user.tf_primary_method:
158
+ session["tf_user_id"] = await _security.state_store.put(
159
+ {"user_id": user.get_id()}, ttl=300
160
+ )
161
+ return redirect(url_for("security.two_factor_token_validation"))
162
+ await _security.login_user(user)
163
+ return redirect(url_for("portal.dashboard"))
164
+
165
+
166
+ def get_google_client():
167
+ return AsyncOAuth2Client(
168
+ client_id=current_app.config.get("GOOGLE_OAUTH_CLIENT_ID"),
169
+ client_secret=current_app.config.get("GOOGLE_OAUTH_CLIENT_SECRET"),
170
+ code_challenge_method="S256",
171
+ )
172
+
173
+
174
+ def get_github_client():
175
+ return AsyncOAuth2Client(
176
+ client_id=current_app.config.get("GITHUB_OAUTH_CLIENT_ID"),
177
+ client_secret=current_app.config.get("GITHUB_OAUTH_CLIENT_SECRET"),
178
+ code_challenge_method="S256",
179
+ )
180
+
181
+
182
+ async def google_discovery():
183
+ async with httpx.AsyncClient(timeout=10) as client:
184
+ response = await client.get(GOOGLE_DISCOVERY_URL)
185
+ response.raise_for_status()
186
+ return response.json()
187
+
188
+
189
+ async def start_oauth(provider):
190
+ if current_user.is_authenticated or not current_app.config.get(
191
+ f"{provider.upper()}_AUTH_ENABLED"
192
+ ):
193
+ return await authentication_failed()
194
+ verifier, nonce = secrets.token_urlsafe(48), secrets.token_urlsafe(32)
195
+ public_url = current_app.config.get("STK_PUBLIC_URL")
196
+ redirect_uri = (
197
+ (public_url.rstrip("/") + url_for(f"public.{provider}_callback"))
198
+ if public_url
199
+ else url_for(f"public.{provider}_callback", _external=True)
200
+ )
201
+ client = get_google_client() if provider == "google" else get_github_client()
202
+ try:
203
+ endpoint = (
204
+ (await google_discovery())["authorization_endpoint"]
205
+ if provider == "google"
206
+ else GITHUB_AUTHORIZE_URL
207
+ )
208
+ auth_url, state = client.create_authorization_url(
209
+ endpoint,
210
+ redirect_uri=redirect_uri,
211
+ scope="openid profile email"
212
+ if provider == "google"
213
+ else "read:user user:email",
214
+ code_verifier=verifier,
215
+ nonce=nonce,
216
+ )
217
+ session[f"oauth_{provider}"] = await _security.state_store.put(
218
+ {
219
+ "state": state,
220
+ "verifier": verifier,
221
+ "nonce": nonce,
222
+ "redirect_uri": redirect_uri,
223
+ },
224
+ ttl=300,
225
+ )
226
+ return redirect(auth_url)
227
+ finally:
228
+ await client.aclose()
229
+
230
+
231
+ async def consume_oauth(provider):
232
+ if not current_app.config.get(f"{provider.upper()}_AUTH_ENABLED"):
233
+ return None
234
+ token = session.pop(f"oauth_{provider}", None)
235
+ state, code = request.args.get("state"), request.args.get("code")
236
+ if not token or not state or not code:
237
+ return None
238
+ transaction = await _security.state_store.pop(token)
239
+ if not transaction or not secrets.compare_digest(state, transaction["state"]):
240
+ return None
241
+ return transaction
242
+
243
+
244
+ @public.route("/login/google")
245
+ async def google_login():
246
+ return await start_oauth("google")
247
+
248
+
249
+ @public.route("/login/github")
250
+ async def github_login():
251
+ return await start_oauth("github")
252
+
253
+
254
+ @public.route("/login/google/callback")
255
+ async def google_callback():
256
+ try:
257
+ transaction = await consume_oauth("google")
258
+ if transaction is None:
259
+ return await authentication_failed()
260
+ discovery = await google_discovery()
261
+ async with get_google_client() as client:
262
+ token = await client.fetch_token(
263
+ discovery["token_endpoint"],
264
+ code=request.args["code"],
265
+ redirect_uri=transaction["redirect_uri"],
266
+ code_verifier=transaction["verifier"],
267
+ )
268
+ async with httpx.AsyncClient(timeout=10) as client:
269
+ response = await client.get(discovery["jwks_uri"])
270
+ response.raise_for_status()
271
+ decoded = jwt.decode(
272
+ token["id_token"],
273
+ KeySet.import_key_set(response.json()),
274
+ algorithms=["RS256"],
275
+ )
276
+ claims = CodeIDToken(
277
+ decoded.claims,
278
+ decoded.header,
279
+ options={
280
+ "iss": {
281
+ "essential": True,
282
+ "values": [
283
+ "https://accounts.google.com",
284
+ "accounts.google.com",
285
+ ],
286
+ },
287
+ "aud": {
288
+ "essential": True,
289
+ "value": current_app.config["GOOGLE_OAUTH_CLIENT_ID"],
290
+ },
291
+ },
292
+ params={
293
+ "client_id": current_app.config["GOOGLE_OAUTH_CLIENT_ID"],
294
+ "nonce": transaction["nonce"],
295
+ "access_token": token["access_token"],
296
+ },
297
+ )
298
+ claims.validate(leeway=60)
299
+ return await handle_oauth_callback("google", token, claims)
300
+ except Exception:
301
+ current_app.logger.exception("Google OAuth failed")
302
+ return await authentication_failed()
303
+
304
+
305
+ @public.route("/login/github/callback")
306
+ async def github_callback():
307
+ try:
308
+ transaction = await consume_oauth("github")
309
+ if transaction is None:
310
+ return await authentication_failed()
311
+ async with get_github_client() as client:
312
+ token = await client.fetch_token(
313
+ GITHUB_TOKEN_URL,
314
+ code=request.args["code"],
315
+ redirect_uri=transaction["redirect_uri"],
316
+ code_verifier=transaction["verifier"],
317
+ )
318
+ async with httpx.AsyncClient(timeout=10) as client:
319
+ headers = {
320
+ "Authorization": f"Bearer {token['access_token']}",
321
+ "Accept": "application/json",
322
+ }
323
+ response = await client.get(f"{GITHUB_API_URL}/user", headers=headers)
324
+ response.raise_for_status()
325
+ user_info = response.json()
326
+ response = await client.get(
327
+ f"{GITHUB_API_URL}/user/emails", headers=headers
328
+ )
329
+ response.raise_for_status()
330
+ primary = next(
331
+ (
332
+ email
333
+ for email in response.json()
334
+ if email.get("primary") and email.get("verified")
335
+ ),
336
+ None,
337
+ )
338
+ if not primary:
339
+ return await authentication_failed()
340
+ user_info.update(email=primary["email"], email_verified=True)
341
+ return await handle_oauth_callback("github", token, user_info)
342
+ except Exception:
343
+ current_app.logger.exception("GitHub OAuth failed")
344
+ return await authentication_failed()
@@ -0,0 +1 @@
1
+ """Module scaffolder for stk -- generates deterministic blueprint structure."""
@@ -0,0 +1,65 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for coding agents working on __App__. `AGENTS.md` is a symlink to this file.
4
+
5
+ __App__ is built on stk, installed as the `stkit` package and imported as `stk`.
6
+ `__app__/app.py` composes it: `Stk(__name__, config=Config, extensions=[...])`, where each
7
+ extension is a package in `__app__/` with `init_app(app)`. Never edit stk's files: they
8
+ are replaced on every upgrade. Override a template or static file by putting one at the
9
+ same path in `__app__/templates/` or `__app__/static/`; `stk/<name>` is the original.
10
+
11
+ ## Where truth lives
12
+
13
+ 1. **The running app.** Ask it before reading files: `uv run stk inspect context --json` (routes with auth, models), `uv run stk inspect commands --json` (every command and option), `uv run stk doctor` (app state, each problem with its fix). `uv run stk new <module>` writes the canonical CRUD extension; copy its patterns.
14
+ 2. **stk's docs** at https://docs.stk.dev, indexed for agents at https://docs.stk.dev/llms.txt. Read the page for your task before writing code.
15
+ 3. **Skills** in `.claude/skills/` (mirrored at `.agents/skills/`): `stk-blueprint`, `stk-api`, `stk-migrate`, `stk-deploy`, and `stk-lessons` (when a gotcha the docs missed costs you time, log one line in `.stk/lessons.md`).
16
+
17
+ ## Commands
18
+
19
+ ```bash
20
+ uv run stk run # Dev server at localhost:5000 (--port 5001 on macOS)
21
+ uv run stk db upgrade # stk's migrations, then the app's
22
+ uv run stk install # Create admin user
23
+ uv run stk doctor # What is wrong, and the command that fixes it
24
+ uv run stk new <module> # Extension + migration, ends at a URL
25
+ uv run stk verify --watch # Re-run affected checks on every save
26
+ uv run stk smoke --json # Real-browser behavioral check
27
+ uv run stk shell -c "await count(User)" # One-shot query against the real app
28
+ uv run stk db revision -m "desc" # Autogenerate in the app's lineage, then: uv run stk db upgrade
29
+ uv run ruff check --fix . && uv run ruff format .
30
+ uv run python -m unittest discover -s tests
31
+ ```
32
+
33
+ ## Rules that fail silently
34
+
35
+ Backend:
36
+ - Async SQLAlchemy, no `db` object. Models inherit `Base` from `stk/extensions.py`. Requests use `g.db_session`; CLI code and tasks use `ext.async_session_factory()`.
37
+ - Every handler is `async def`; every DB call is awaited. Import from `quart` and `quart_security`, never `flask*`.
38
+ - Every relationship uses `lazy="selectin"`. Never read a relationship on a row you just `add()`ed: load it with `await session.get(...)` or a query, or you get `MissingGreenlet`.
39
+ - `User.from_dict()`, `Activity.register()`, `Session.create_session()` and the auth signal handlers are async.
40
+ - Pagination is manual: `offset().limit()` plus `select(func.count())`. List endpoints return `orjson` via `Response(json.dumps(data), content_type="application/json")`.
41
+ - Mutations arrive as `{item: {...}}`: read `json_data.get("item", {})`.
42
+ - `Activity.register()` stores audit events and does not broadcast. `broadcast()` needs an explicit recipient and reaches only sockets on the same worker.
43
+ - SQLite migrations: DDL is not transactional; a new NOT NULL column needs `server_default` in migration and model; keep the constraint naming convention on `Base.metadata`.
44
+ - `stk verify` blocks drops, renames, retypes, and NOT NULL adds without a default in `upgrade()`. A rename is usually a mistaken drop + add from autogenerate; mark a deliberate one with `# destructive: <reason>`.
45
+ - Tests: subclass `TestingConfig` (`stk/settings.py`), never `Config`; `.env` loads at import. Pin any other env-driven switch a test depends on.
46
+ - Apps attach tables to core ones by foreign key (`user.id`); core models are fixed. App revisions depend on stk's head, so never merge heads by hand.
47
+ - An app overrides a template or static file by putting one at the same path; `stk/<name>` is the original. Override a block, do not copy the layout. Python is never overridden.
48
+
49
+ Frontend:
50
+ - Vue Options API (`data()`, `methods`, `mounted()`), never Composition API. Delimiters are `${}`. Every page uses `mixins: [layoutMixin]` and calls `registerStkComponents(app)` before mount.
51
+ - Server data JSON `<script>` tags go in `{% block js %}`, never `{% block content %}`: Vue strips scripts inside `#app`.
52
+ - Template expressions cannot see page globals: `app.config.globalProperties.name = value` before mount. The production build fails silently, so look at the rendered page.
53
+ - Vuetify 4 uses MD3 type classes (`text-title-large`, not `text-h6`); MD2 classes do nothing. Icons are Tabler (`ti ti-*`), never MDI.
54
+ - Logout is POST-only (`layoutMixin.logout()`).
55
+
56
+ Security:
57
+ - stk owns authentication in-process. Do not swap in a third-party auth service unless that is the task.
58
+ - All mutations require CSRF. A sessionless webhook opts out with `@csrf_exempt` from `stk.security` under the route decorator and must authenticate the caller itself (e.g. HMAC).
59
+ - Public password registration stays off by default. OAuth never auto-links emails and still enforces local MFA.
60
+ - Agent browser login (`STK_ENABLE_AGENT_LOGIN`) is development/testing only; startup fails elsewhere.
61
+ - Never commit secrets, databases, reviews, or machine paths: `stk verify` fails on them.
62
+
63
+ Product:
64
+ - Rename an app with `APP_NAME` / `APP_TAGLINE` / `APP_DESCRIPTION` and its own `templates/_brand.html` and favicon. Never edit names across templates.
65
+ - Before reporting UI work done, run `uv run stk smoke` and look at a screenshot with real rows. A 200 and green tests do not prove a page renders.
@@ -0,0 +1,41 @@
1
+ # syntax=docker/dockerfile:1.4
2
+ FROM python:3.12-slim AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Install build deps + uv in one layer (git, for dependencies pinned to a commit)
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential python3-dev git \
9
+ && rm -rf /var/lib/apt/lists/* \
10
+ && pip install --no-cache-dir uv
11
+
12
+ # Install dependencies
13
+ COPY pyproject.toml uv.lock ./
14
+ RUN uv sync --extra full --frozen --no-dev --no-install-project
15
+
16
+ # Runtime
17
+ FROM python:3.12-slim
18
+
19
+ WORKDIR /app
20
+ ENV PATH="/app/.venv/bin:$PATH" \
21
+ PYTHONDONTWRITEBYTECODE=1 \
22
+ PYTHONUNBUFFERED=1
23
+
24
+ # Install runtime deps and create user
25
+ RUN apt-get update && apt-get install -y --no-install-recommends curl libexpat1 \
26
+ && rm -rf /var/lib/apt/lists/* \
27
+ && useradd -m -u 1000 stk
28
+
29
+ # Copy venv and app
30
+ COPY --from=builder --chown=stk:stk /app/.venv ./.venv
31
+ COPY --chown=stk:stk . .
32
+
33
+ # Create instance dir with correct ownership
34
+ RUN mkdir -p /app/instance && chown stk:stk /app/instance
35
+
36
+ USER stk
37
+
38
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
39
+ CMD curl -f http://localhost:5000/ || exit 1
40
+
41
+ CMD ["uvicorn", "__app__.app:app", "--host", "0.0.0.0", "--port", "5000"]
@@ -0,0 +1,16 @@
1
+ # __App__
2
+
3
+ Built on [stk](https://github.com/level09/stk) __stk_version__. The app composes stk
4
+ from `__app__/app.py` and never edits stk's files.
5
+
6
+ ```bash
7
+ uv run stk db upgrade # stk's migrations, then the app's
8
+ uv run stk install # admin user
9
+ uv run stk run # http://localhost:5000
10
+ uv run stk new <module> # an extension: blueprint, model, page, migration
11
+ uv run stk verify && uv run stk smoke
12
+ ```
13
+
14
+ Upgrade stk by reading its release note, raising the `stkit` range in
15
+ `dependencies` if the major version changed, then `uv lock --upgrade-package stkit`,
16
+ `uv sync`, `uv run stk db upgrade`, `uv run stk verify`, and `uv run stk smoke`.
@@ -0,0 +1 @@
1
+ """__App__."""
@@ -0,0 +1,8 @@
1
+ from stk import Stk
2
+
3
+ from __app__.settings import Config
4
+
5
+ # Each extension is a package with init_app(app); order is precedence.
6
+ extensions = []
7
+
8
+ app = Stk(__name__, config=Config, extensions=extensions)
@@ -0,0 +1,7 @@
1
+ import os
2
+
3
+ import stk
4
+
5
+
6
+ class Config(stk.Config):
7
+ APP_NAME = os.environ.get("APP_NAME", "__App__")
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: stk-api
3
+ description: |
4
+ Generate async CRUD API endpoints for an existing stk model. Use when adding API routes, REST endpoints, or CRUD operations to an stk blueprint.
5
+ argument-hint: "[ModelName]"
6
+ ---
7
+
8
+ # Generate CRUD API for $ARGUMENTS
9
+
10
+ ## Current state
11
+
12
+ Models available:
13
+ !`grep -rn "class.*Base" */*/models.py 2>/dev/null`
14
+
15
+ Existing API routes:
16
+ !`grep -rn "@bp_[a-z_]*\.\(get\|post\|put\|delete\|patch\|route\)" */*/views.py 2>/dev/null | head -30`
17
+
18
+ ## Steps
19
+
20
+ 1. **Find the model** named `$ARGUMENTS` in the codebase. Read its full definition to understand fields, relationships, and existing `to_dict()`/`from_dict()`.
21
+
22
+ 2. **Generate these endpoints** in the model's blueprint views.py:
23
+
24
+ - `GET /api/<plural>` — List with pagination, search, filtering
25
+ - `POST /api/<singular>/` — Create (note trailing slash)
26
+ - `POST /api/<singular>/<id>` — Update (POST, not PUT — stk convention)
27
+ - `DELETE /api/<singular>/<id>` — Delete
28
+
29
+ 3. **Follow the conventions** in https://docs.stk.dev/api/: blueprint-level auth, `{item: {...}}`
30
+ payloads, `{items, total, perPage}` list responses via `orjson`, `Activity.register` on
31
+ every mutation, rollback plus `log.exception` on failure, and `{"message": ...}` with
32
+ 400/404/412. The reference implementation is what `uv run stk new` writes
33
+ (`render_views` in `stk/scaffold/templates.py`); match it.
34
+
35
+ 4. **List endpoint:** `PER_PAGE = 25`, `page`/`per_page` params, an optional `search` param
36
+ with `ilike` on the text fields, and the total counted from `query.subquery()` so filters
37
+ apply to it.
38
+
39
+ 5. **Create/Update:** create calls `await Model().from_dict(data)` and `flush()`es before the
40
+ audit entry so the ID exists; update stores `to_dict()` before changing the row and logs
41
+ old and new.
42
+
43
+ 6. **If a page route doesn't exist yet**, add one:
44
+ ```python
45
+ @bp.get("/<plural>/")
46
+ async def <plural>_page():
47
+ return await render_template("<name>/index.html")
48
+ ```
49
+
50
+ 7. **Verify:**
51
+ - Run `uv run ruff check --fix . && uv run ruff format .`
52
+ - Run `uv run stk verify`
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: stk-blueprint
3
+ description: |
4
+ Scaffold a new extension in an stk app: blueprint, model, page, sidebar entry, and Alembic migration. Use when creating a new feature module, adding a new section to the app, or scaffolding a blueprint.
5
+ argument-hint: "[blueprint-name]"
6
+ ---
7
+
8
+ # Scaffold stk Blueprint
9
+
10
+ Create a complete extension for `$ARGUMENTS` in the app package (run from the app's directory).
11
+
12
+ ## Workflow
13
+
14
+ The scaffolder generates the full module structure. Run it first, then customize domain fields and logic.
15
+
16
+ ```bash
17
+ uv run stk new <name>
18
+ ```
19
+
20
+ `<name>` must be lowercase snake_case (e.g. `blog_post`, `invoice_line`). Reserved names (`user`, `role`, `portal`, `public`, `session`, `admin`, etc.) are rejected with a clear error.
21
+
22
+ The command generates, inside the app package (`myapp/`):
23
+ - `<name>/__init__.py` with `init_app(app)`: registers the blueprint and appends the sidebar entry to `app.navigation`
24
+ - `<name>/models.py`, `<name>/views.py`, `<name>/templates/<name>/index.html`
25
+ - Adds `<name>` to `extensions` in the app module (import + list entry)
26
+ - Generates AND applies the migration in the app's lineage, then prints the page URL
27
+
28
+ It never edits a stk file. Then:
29
+ 1. Customize `<name>/models.py` -- add/rename fields to fit your domain; point at core tables by foreign key (`ForeignKey("user.id")`)
30
+ 2. If you changed the model after scaffolding: `uv run stk db revision -m "update <name>"` and review, then `uv run stk db upgrade`
31
+ (only needed for post-scaffold model edits; the initial migration is already applied. With `--no-migrate`, the first revision is manual)
32
+ 3. Verify: `uv run stk verify && uv run stk smoke`
33
+
34
+ ## Conventions
35
+
36
+ The generated files already follow every stk convention. When you change them, the rules
37
+ are in `CLAUDE.md` and the details in https://docs.stk.dev/models/, https://docs.stk.dev/api/, and
38
+ https://docs.stk.dev/frontend/. Read the page before editing that layer.
39
+
40
+ ## Before you report done
41
+
42
+ Each line is a mistake an agent made while building a real app on stk.
43
+
44
+ - Lint and format only the files you changed (`uv run ruff format path/a.py path/b.py`), never a whole package another person owns.
45
+ - Tests: give test-only blueprints names an app cannot already use.
46
+ - Migrations: check each command's exit code, never behind a pipe to `tail`. Never delete a revision file before `stk db downgrade <target>` has succeeded.
47
+ - Tables inside Vue's in-DOM templates: never put `<tbody>`/`<tr>` inside `<v-table>` or another custom element; the browser parser moves the rows out before Vue mounts and they never render. Use `v-data-table`, a native `<table>`, or flex rows.
48
+ - Verify UI in a browser: `uv run stk smoke`, then look at a screenshot of the page with real rows. A 200 and green tests do not prove columns, labels or buttons render.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: stk-deploy
3
+ description: Deploy an stk app to production on a fresh VPS with HTTPS, systemd, and a hardened OS. Use when taking an stk app live, deploying to a server, or setting up production hosting for a domain.
4
+ argument-hint: "[domain]"
5
+ ---
6
+
7
+ # Deploy stk App to Production
8
+
9
+ One-shot production install via `deploy.sh` (framework root). Installs Caddy
10
+ (auto-TLS, security headers, static caching), a systemd service, a non-root app
11
+ user, generated secrets, and hardens the box (SSH key-only, fail2ban, ufw).
12
+ SQLite by default, PostgreSQL+Redis optional.
13
+
14
+ ## Prerequisites
15
+
16
+ 1. Fresh Ubuntu/Debian VPS with root SSH access.
17
+ 2. DNS A record for the domain pointing at the server (Caddy needs it for TLS).
18
+ 3. App repo pushed to GitHub **and public**, or see the private-repo note below.
19
+
20
+ ## Install (non-interactive)
21
+
22
+ Run from your machine against the server, or on the server as root:
23
+
24
+ ```bash
25
+ wget -qO /tmp/deploy.sh https://raw.githubusercontent.com/level09/stk/master/deploy.sh
26
+ sudo DOMAIN=example.com REPO=<github-user>/<repo> bash /tmp/deploy.sh
27
+ ```
28
+
29
+ Every variable and what the script installs: https://docs.stk.dev/deployment/.
30
+
31
+ Ends at a live `https://<domain>` with an admin login. Credentials are saved
32
+ on the server at `/home/<app-user>/.credentials`.
33
+
34
+ ## Hard warnings
35
+
36
+ - **Never re-run deploy.sh on a live app.** It is install-only: it deletes and
37
+ re-clones the app directory. With SQLite the database is
38
+ `instance/stk.db` *inside* that directory, so a re-run destroys production
39
+ data. Use the update flow below.
40
+ - **Private repos fail silently at the clone step**: the script clones over
41
+ plain HTTPS. Either make the repo public, or pre-seed a read-only GitHub
42
+ token on the server and clone via `git@github.com:` manually first.
43
+
44
+ ## Update flow (every deploy after the first)
45
+
46
+ ```bash
47
+ ssh <app-user>@<domain>
48
+ cd ~/<domain>
49
+ git pull
50
+ uv sync --frozen --no-dev
51
+ uv run --no-sync stk db upgrade # stk's migrations and the app's, if any are new
52
+ sudo systemctl restart <domain>.service
53
+ ```
54
+
55
+ The app user's sudoers allows managing only its own service.
56
+
57
+ ## Verify
58
+
59
+ - `curl -I https://<domain>` returns 200/302 with the security headers.
60
+ - Log in as admin, create a throwaway record, restart the service, confirm it
61
+ survives (proves the DB path).