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/checks.py ADDED
@@ -0,0 +1,634 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Quick sanity checks against the app found from `[tool.stk]` - run before deploying.
4
+ No frameworks, no mocking, just real code paths. Checks marked `repo_only` guard
5
+ stk's own docs and run only inside the stk repository.
6
+
7
+ Usage:
8
+ uv run python -m stk.checks
9
+ uv run python -m stk.checks -v # verbose
10
+ """
11
+
12
+ import asyncio
13
+ import sys
14
+
15
+ from stk.cli.base import project_root
16
+
17
+ VERBOSE = "-v" in sys.argv
18
+ PASSED = 0
19
+ FAILED = 0
20
+ ROOT = project_root()
21
+ # stk's own checkout: its docs, skills, and generated reference are checked too.
22
+ IN_STK_REPO = (ROOT / "stk" / "__init__.py").is_file() and (
23
+ ROOT / "docs/src/content/docs"
24
+ ).is_dir()
25
+
26
+
27
+ def check(name, repo_only=False):
28
+ """Decorator to register a check"""
29
+
30
+ def decorator(f):
31
+ async def wrapper(app):
32
+ if repo_only and not IN_STK_REPO:
33
+ return True
34
+ global PASSED, FAILED
35
+ try:
36
+ result = f(app)
37
+ if asyncio.iscoroutine(result):
38
+ await result
39
+ PASSED += 1
40
+ print(f" \033[32m✓\033[0m {name}")
41
+ return True
42
+ except Exception as e:
43
+ FAILED += 1
44
+ print(f" \033[31m✗\033[0m {name}")
45
+ if VERBOSE:
46
+ print(f" → {e}")
47
+ return False
48
+
49
+ wrapper._check_name = name
50
+ return wrapper
51
+
52
+ return decorator
53
+
54
+
55
+ # =============================================================================
56
+ # CHECKS
57
+ # =============================================================================
58
+
59
+
60
+ @check("App boots without errors")
61
+ def check_app_boots(app):
62
+ assert app is not None
63
+ assert app.config["SECRET_KEY"]
64
+
65
+
66
+ @check("Database connection works")
67
+ async def check_database(app):
68
+ import stk.extensions as ext
69
+
70
+ async with ext.engine.connect() as conn:
71
+ from sqlalchemy import text
72
+
73
+ await conn.execute(text("SELECT 1"))
74
+
75
+
76
+ @check("User model queryable")
77
+ async def check_user_model(app):
78
+ from sqlalchemy import select
79
+
80
+ import stk.extensions as ext
81
+ from stk.user.models import User
82
+
83
+ async with ext.async_session_factory() as session:
84
+ await session.execute(select(User).limit(1))
85
+
86
+
87
+ @check("Role model queryable")
88
+ async def check_role_model(app):
89
+ from sqlalchemy import select
90
+
91
+ import stk.extensions as ext
92
+ from stk.user.models import Role
93
+
94
+ async with ext.async_session_factory() as session:
95
+ await session.execute(select(Role).limit(1))
96
+
97
+
98
+ @check("All blueprints register")
99
+ def check_blueprints(app):
100
+ blueprints = list(app.blueprints.keys())
101
+ required = ["users", "public", "portal", "security", "ws"]
102
+ for bp in required:
103
+ assert bp in blueprints, f"Missing blueprint: {bp}"
104
+
105
+
106
+ @check("Critical routes exist")
107
+ def check_routes(app):
108
+ rules = [r.rule for r in app.url_map.iter_rules()]
109
+
110
+ critical_routes = [
111
+ "/",
112
+ "/login",
113
+ "/dashboard/",
114
+ "/ws",
115
+ "/health",
116
+ ]
117
+ for route in critical_routes:
118
+ assert route in rules, f"Missing route: {route}"
119
+
120
+
121
+ @check("Security config is sane")
122
+ def check_security_config(app):
123
+ assert app.config["SECURITY_PASSWORD_LENGTH_MIN"] >= 8
124
+
125
+
126
+ @check("Async session factory initialized")
127
+ def check_session_factory(app):
128
+ import stk.extensions as ext
129
+
130
+ assert ext.engine is not None, "Engine not initialized"
131
+ assert ext.async_session_factory is not None, "Session factory not initialized"
132
+
133
+
134
+ @check("All models queryable")
135
+ async def check_all_models(app):
136
+ from sqlalchemy import select
137
+
138
+ import stk.extensions as ext
139
+ from stk.user.models import Activity, OAuth, Session, WebAuthn
140
+
141
+ async with ext.async_session_factory() as session:
142
+ for model in [Activity, Session, OAuth, WebAuthn]:
143
+ await session.execute(select(model).limit(1))
144
+
145
+
146
+ @check("Admin routes exist")
147
+ def check_admin_routes(app):
148
+ rules = [r.rule for r in app.url_map.iter_rules()]
149
+ admin_routes = [
150
+ "/users/",
151
+ "/roles/",
152
+ "/activities/",
153
+ "/api/users",
154
+ "/api/roles",
155
+ "/api/activities",
156
+ ]
157
+ for route in admin_routes:
158
+ assert route in rules, f"Missing admin route: {route}"
159
+
160
+
161
+ @check("Auth routes exist")
162
+ def check_auth_routes(app):
163
+ rules = [r.rule for r in app.url_map.iter_rules()]
164
+ auth_routes = ["/login", "/register", "/logout", "/change"]
165
+ for route in auth_routes:
166
+ assert route in rules, f"Missing auth route: {route}"
167
+
168
+
169
+ @check("WebAuthn enabled")
170
+ def check_webauthn(app):
171
+ assert app.config.get("SECURITY_WEBAUTHN") is True
172
+ assert app.config.get("SECURITY_WAN_ALLOW_AS_FIRST_FACTOR") is True
173
+
174
+
175
+ @check("2FA enabled")
176
+ def check_two_factor(app):
177
+ assert app.config.get("SECURITY_TWO_FACTOR") is True
178
+ assert "authenticator" in app.config.get("SECURITY_TWO_FACTOR_ENABLED_METHODS", [])
179
+
180
+
181
+ @check("Background tasks module loads")
182
+ def check_tasks_module(app):
183
+ from stk.tasks import run_in_background
184
+
185
+ assert callable(run_in_background)
186
+
187
+
188
+ @check("WebSocket module loads")
189
+ def check_websocket_module(app):
190
+ from stk.websocket import broadcast, get_connected_users
191
+
192
+ assert callable(broadcast)
193
+ assert callable(get_connected_users)
194
+
195
+
196
+ @check("No Celery dependency")
197
+ def check_no_celery(app):
198
+ import importlib.util
199
+
200
+ assert importlib.util.find_spec("celery") is None, "Celery should not be installed"
201
+
202
+
203
+ @check("Templates exist")
204
+ def check_templates(app):
205
+ required = [
206
+ "layout.html",
207
+ "dashboard.html",
208
+ "index.html",
209
+ "401.html",
210
+ "404.html",
211
+ "500.html",
212
+ "cms/users.html",
213
+ "cms/roles.html",
214
+ "cms/activities.html",
215
+ ]
216
+ for tpl in required:
217
+ app.jinja_env.get_template(tpl)
218
+
219
+
220
+ @check("Vue interpolations contain no braces")
221
+ def check_vue_delimiters(app):
222
+ # The `${ }` delimiter ends at the first `}`, so `${ (x || {}).y }` compiles to
223
+ # a broken render function and the page stays blank with one console error.
224
+ import re
225
+
226
+ hazard = re.compile(r"\$\{[^}]*\{")
227
+ # Script bodies hold JS template literals, where `${ {...} }` is valid; blank them
228
+ # but keep their newlines so reported line numbers still match the file.
229
+ script = re.compile(r"(<script\b[^>]*>)(.*?)(</script>)", re.S | re.I)
230
+ import os
231
+
232
+ problems = []
233
+ pages = {path for folder in app.template_folders for path in folder.rglob("*.html")}
234
+ for path in sorted(pages):
235
+ text = script.sub(
236
+ lambda m: m.group(1) + "\n" * m.group(2).count("\n") + m.group(3),
237
+ path.read_text(),
238
+ )
239
+ for match in hazard.finditer(text):
240
+ line = text.count("\n", 0, match.start()) + 1
241
+ problems.append(f"{os.path.relpath(path)}:{line}")
242
+
243
+ assert not problems, (
244
+ "`{` inside `${ }` (move it into a method):\n " + "\n ".join(problems)
245
+ )
246
+
247
+
248
+ @check("Static assets exist")
249
+ def check_static_assets(app):
250
+ required = [
251
+ "js/vue.min.js",
252
+ "js/vuetify.min.js",
253
+ "js/config.js",
254
+ "js/components/index.js",
255
+ "css/vuetify.min.css",
256
+ "css/layout.css",
257
+ "css/app.css",
258
+ ]
259
+ for asset in required:
260
+ assert app.static_file(asset), f"Missing static asset: {asset}"
261
+
262
+
263
+ @check("GET /health returns 200")
264
+ async def check_health_endpoint(app):
265
+ client = app.test_client()
266
+ response = await client.get("/health")
267
+ assert response.status_code == 200, f"Expected 200, got {response.status_code}"
268
+ data = await response.get_json()
269
+ assert data["status"] == "ok", f"Health status: {data['status']}"
270
+ assert data["checks"]["database"] == "ok"
271
+
272
+
273
+ @check("User model has lockout fields")
274
+ def check_lockout_fields(app):
275
+ from stk.user.models import User
276
+
277
+ assert hasattr(User, "failed_login_count"), "Missing failed_login_count"
278
+ assert hasattr(User, "locked_until"), "Missing locked_until"
279
+
280
+
281
+ @check("Admin password reset route exists")
282
+ def check_admin_reset_route(app):
283
+ rules = [r.rule for r in app.url_map.iter_rules()]
284
+ assert "/api/user/<int:id>/reset-password" in rules, (
285
+ "Missing admin reset-password route"
286
+ )
287
+
288
+
289
+ @check("GET / returns 200")
290
+ async def check_http_index(app):
291
+ client = app.test_client()
292
+ response = await client.get("/")
293
+ assert response.status_code == 200, f"Expected 200, got {response.status_code}"
294
+
295
+
296
+ @check("GET /dashboard/ requires auth")
297
+ async def check_auth_redirect(app):
298
+ client = app.test_client()
299
+ response = await client.get("/dashboard/")
300
+ assert response.status_code in (302, 401), (
301
+ f"Expected 302/401, got {response.status_code}"
302
+ )
303
+
304
+
305
+ @check("GET /api/users requires auth")
306
+ async def check_api_requires_auth(app):
307
+ client = app.test_client()
308
+ response = await client.get("/api/users")
309
+ assert response.status_code in (302, 401), (
310
+ f"Expected 302/401, got {response.status_code}"
311
+ )
312
+
313
+
314
+ @check("Reported route auth matches real requests")
315
+ async def check_route_auth_is_honest(app):
316
+ """Every route the report calls guarded must actually reject anonymous access.
317
+
318
+ Agents read `quart inspect routes` to decide what is exposed, so a route
319
+ claiming protection it does not have is the expensive direction of wrong.
320
+ """
321
+ from stk.cli.reports import build_routes_report
322
+
323
+ client = app.test_client()
324
+ liars = []
325
+ for route in build_routes_report(app):
326
+ if not route["auth"]["required"] or "GET" not in route["methods"]:
327
+ continue
328
+ if route["arguments"] or route["endpoint"].endswith(".static"):
329
+ continue
330
+ response = await client.get(route["rule"])
331
+ location = response.headers.get("Location", "")
332
+ denied = response.status_code in (401, 403) or (
333
+ response.status_code in (301, 302) and "/login" in location
334
+ )
335
+ if not denied:
336
+ liars.append(f"{route['rule']} -> HTTP {response.status_code}")
337
+
338
+ assert not liars, "Routes reported as guarded but reachable: " + ", ".join(liars)
339
+
340
+
341
+ @check("CLI commands registered")
342
+ def check_cli_commands(app):
343
+ from stk.cli.main import main
344
+
345
+ commands = list(main.commands)
346
+ required = [
347
+ "init",
348
+ "run",
349
+ "create-db",
350
+ "install",
351
+ "create",
352
+ "add-role",
353
+ "reset",
354
+ "db",
355
+ "shell",
356
+ ]
357
+ for cmd in required:
358
+ assert cmd in commands, f"Missing CLI command: {cmd}"
359
+
360
+
361
+ @check("Docs name only real commands, flags, and settings", repo_only=True)
362
+ def check_docs_match_code(app):
363
+ # Docs, skills, and agent context drift silently: a renamed flag or an invented
364
+ # setting reads fine until someone runs it. Every `stk ...` line inside code
365
+ # and every SECURITY_*/STK_* key must resolve against the code.
366
+ import re
367
+ import shlex
368
+ from pathlib import Path
369
+
370
+ import click
371
+ import quart_security
372
+
373
+ from stk.cli.main import main
374
+
375
+ root = ROOT
376
+ pages = [
377
+ root / "README.md",
378
+ root / "CLAUDE.md",
379
+ root / "SECURITY.md",
380
+ *root.glob(".claude/skills/**/*.md"),
381
+ *root.glob("docs/src/content/docs/*.md*"),
382
+ ]
383
+ sources = [
384
+ *root.glob("stk/**/*.py"),
385
+ root / "stk/scaffold/app/docker-compose.yml",
386
+ root / "stk/scaffold/app/env-sample",
387
+ *Path(quart_security.__file__).parent.glob("*.py"),
388
+ ]
389
+ known_settings = "\n".join(p.read_text() for p in sources)
390
+ commands = {**main.commands, **app.cli.commands}
391
+ help_opts = {"-h", "--help"}
392
+
393
+ def resolve(tokens):
394
+ command = None
395
+ for token in tokens:
396
+ if token.startswith("-") and not token[1:].isdigit():
397
+ params = command.params if command else main.params
398
+ opts = {o for p in params for o in p.opts + p.secondary_opts}
399
+ if token.split("=")[0] not in opts | help_opts:
400
+ return f"unknown option {token}"
401
+ elif command is None or isinstance(command, click.Group):
402
+ children = commands if command is None else command.commands
403
+ if token not in children:
404
+ return f"unknown command {token}"
405
+ command = children[token]
406
+ return None
407
+
408
+ code = re.compile(r"```[^\n]*\n(.*?)```|`([^`\n]+)`", re.S)
409
+ invocation = re.compile(r"^(?:\$\s*)?(?:uv run\s+)?stk\s+(.+)$")
410
+ problems = []
411
+ for page in pages:
412
+ text = page.read_text()
413
+ name = page.relative_to(root)
414
+ for block in code.finditer(text):
415
+ for line in (block.group(1) or block.group(2)).splitlines():
416
+ for segment in re.split(r"&&|\|\||[|;]", line.split(" #")[0]):
417
+ match = invocation.match(segment.strip())
418
+ if not match:
419
+ continue
420
+ try:
421
+ tokens = shlex.split(match.group(1))
422
+ except ValueError:
423
+ tokens = match.group(1).split()
424
+ if error := resolve(tokens):
425
+ problems.append(f"{name}: `stk {match.group(1)}` {error}")
426
+ for key in sorted(set(re.findall(r"\b(?:SECURITY|STK)_[A-Z0-9_]+\b", text))):
427
+ if key not in known_settings:
428
+ problems.append(f"{name}: setting {key} is not read anywhere")
429
+
430
+ assert not problems, "Docs drifted from code:\n " + "\n ".join(problems)
431
+
432
+
433
+ @check(
434
+ "Docs are one source: indexed, generated, and pointing at real files",
435
+ repo_only=True,
436
+ )
437
+ def check_docs_single_source(app):
438
+ # Agents read AGENTS.md first and follow its pointers into docs/, so a page the
439
+ # index misses is invisible, and a pointer to a moved file sends them nowhere.
440
+ import re
441
+
442
+ from click.testing import CliRunner
443
+
444
+ from stk.cli.main import main
445
+
446
+ root = ROOT
447
+ docs = root / "docs/src/content/docs"
448
+ index = (root / "CLAUDE.md").read_text()
449
+ problems = [
450
+ f"CLAUDE.md does not index {page.relative_to(root)}"
451
+ for page in sorted(docs.glob("*.md"))
452
+ if str(page.relative_to(root)) not in index
453
+ ]
454
+
455
+ readers = [
456
+ root / "CLAUDE.md",
457
+ root / "README.md",
458
+ *root.glob(".claude/skills/**/*.md"),
459
+ ]
460
+ readers += [*docs.glob("*.md*")]
461
+ path_like = re.compile(
462
+ r"`([\w.-]+(?:/[\w.-]+)+\.(?:md|mdx|py|js|html|css|txt|sh|webp))`"
463
+ )
464
+ # Docs also name files of an app (`myapp/...`, from the app template), app
465
+ # overrides of core files, and core templates by their `stk/` namespace.
466
+ scaffold = root / "stk/scaffold/app"
467
+
468
+ def resolves(path):
469
+ app_path = path.removeprefix("myapp/")
470
+ return any(
471
+ candidate.exists()
472
+ for candidate in (
473
+ root / path,
474
+ scaffold / path.replace("myapp/", "__app__/", 1),
475
+ root / "stk" / app_path,
476
+ root / "stk/templates" / path.removeprefix("stk/"),
477
+ )
478
+ )
479
+
480
+ for page in readers:
481
+ for path in path_like.findall(page.read_text()):
482
+ if not resolves(path):
483
+ problems.append(f"{page.relative_to(root)} points at missing {path}")
484
+
485
+ reference = docs / "cli.md"
486
+ generated = CliRunner().invoke(main, ["inspect", "commands", "--markdown"])
487
+ assert generated.exit_code == 0, generated.output
488
+ if reference.read_text() != generated.output:
489
+ problems.append(
490
+ f"{reference.relative_to(root)} is stale"
491
+ " → uv run stk inspect commands --markdown > docs/src/content/docs/cli.md"
492
+ )
493
+
494
+ settings = (root / "stk/settings.py").read_text()
495
+ configuration = (docs / "configuration.md").read_text()
496
+ for name in sorted(set(re.findall(r'environ\.get\(\s*"([A-Z0-9_]+)"', settings))):
497
+ if f"`{name}`" not in configuration:
498
+ problems.append(
499
+ f"configuration.md does not list {name}, read in stk/settings.py"
500
+ )
501
+
502
+ # Apps get their own CLAUDE.md from the template; its rules must be stk's.
503
+ rules = index[index.index("## Rules that fail silently") :]
504
+ if rules not in (root / "stk/scaffold/app/CLAUDE.md").read_text():
505
+ problems.append(
506
+ "stk/scaffold/app/CLAUDE.md does not carry CLAUDE.md's rules section"
507
+ " → copy it from '## Rules that fail silently' to the end"
508
+ )
509
+
510
+ # Agents load these whole on every task; an index past ~8KB stops being read
511
+ # as an index. Move detail into a docs page instead of raising the budget.
512
+ budgets = {root / "CLAUDE.md": 8192, root / "stk/scaffold/app/CLAUDE.md": 8192}
513
+ budgets |= dict.fromkeys(root.glob(".claude/skills/*/SKILL.md"), 4096)
514
+ for page, ceiling in budgets.items():
515
+ if (size := len(page.read_bytes())) > ceiling:
516
+ problems.append(
517
+ f"{page.relative_to(root)} is {size} bytes, over its {ceiling} budget"
518
+ " → move detail into a docs page and link it"
519
+ )
520
+
521
+ assert not problems, "Docs are out of sync:\n " + "\n ".join(problems)
522
+
523
+
524
+ @check("Migrations do not destroy data")
525
+ def check_migrations_are_additive(app):
526
+ # Autogenerate turns a renamed field into drop + add, and SQLite batch mode
527
+ # rebuilds the table, so a lost column reads like a normal diff in review.
528
+ # Mark a deliberate one with a `# destructive: <reason>` comment in upgrade().
529
+ import ast
530
+
531
+ from stk.migrations import CORE_VERSIONS, app_versions
532
+
533
+ destructive = {"drop_table", "drop_column", "rename_table"}
534
+ problems = []
535
+ for path in sorted([*CORE_VERSIONS.glob("*.py"), *app_versions(app).glob("*.py")]):
536
+ source = path.read_text()
537
+ for func in ast.parse(source).body:
538
+ if not (isinstance(func, ast.FunctionDef) and func.name == "upgrade"):
539
+ continue
540
+ if "# destructive:" in ast.get_source_segment(source, func):
541
+ continue
542
+ for node in ast.walk(func):
543
+ if not (
544
+ isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
545
+ ):
546
+ continue
547
+ op, kwargs = node.func.attr, {k.arg for k in node.keywords}
548
+ where = f"{path.name}:{node.lineno}"
549
+ if op in destructive:
550
+ problems.append(f"{where} {op}")
551
+ elif op == "alter_column" and kwargs & {"new_column_name", "type_"}:
552
+ problems.append(f"{where} alter_column renames or retypes")
553
+ elif op == "add_column":
554
+ column = next(
555
+ (a for a in node.args if isinstance(a, ast.Call)), None
556
+ )
557
+ flags = {k.arg: k.value for k in column.keywords} if column else {}
558
+ not_null = getattr(flags.get("nullable"), "value", True) is False
559
+ if not_null and "server_default" not in flags:
560
+ problems.append(
561
+ f"{where} add_column NOT NULL without server_default"
562
+ )
563
+
564
+ assert not problems, (
565
+ "Destructive migration steps (add `# destructive: <reason>` to upgrade()"
566
+ " if intended):\n " + "\n ".join(problems)
567
+ )
568
+
569
+
570
+ @check("Repository publishes nothing private")
571
+ def check_repo_is_publishable(app):
572
+ # The repo is public and cloned as a template: a committed security review,
573
+ # database, or machine path leaks the moment it is pushed, and history keeps it.
574
+ import re
575
+ import subprocess
576
+
577
+ root = ROOT
578
+ if not (root / ".git").exists():
579
+ return
580
+ tracked = subprocess.run(
581
+ ["git", "ls-files", "-z"], cwd=root, capture_output=True, check=True, text=True
582
+ ).stdout.split("\0")
583
+ private_path = re.compile(
584
+ r"(^|/)(security-review[^/]*|\.env|instance/.*|.*\.sqlite3?|.*\.db|.*\.pem|.*\.key)$"
585
+ )
586
+ private_text = re.compile(
587
+ r"/(?:Users|home)/[a-z][\w.-]*/|-----BEGIN [A-Z ]*PRIVATE KEY-----"
588
+ )
589
+ problems = [
590
+ f"{path}: private file" for path in tracked if private_path.search(path)
591
+ ]
592
+ for path in tracked:
593
+ file = root / path
594
+ if not path or not file.is_file() or file.is_symlink():
595
+ continue
596
+ try:
597
+ text = file.read_text()
598
+ except UnicodeDecodeError:
599
+ continue
600
+ if match := private_text.search(text):
601
+ line = text.count("\n", 0, match.start()) + 1
602
+ problems.append(f"{path}:{line}: {match.group(0)}")
603
+
604
+ assert not problems, "Private content is tracked:\n " + "\n ".join(problems)
605
+
606
+
607
+ # =============================================================================
608
+ # RUNNER
609
+ # =============================================================================
610
+
611
+
612
+ async def run_checks():
613
+ from stk.cli.base import load_app
614
+
615
+ print("\n\033[1mRunning checks...\033[0m\n")
616
+
617
+ app = load_app()
618
+ checks = [v for v in globals().values() if hasattr(v, "_check_name")]
619
+
620
+ async with app.test_app():
621
+ for check_fn in checks:
622
+ await check_fn(app)
623
+
624
+ print()
625
+ if FAILED == 0:
626
+ print(f"\033[32m\033[1m✓ All {PASSED} checks passed\033[0m\n")
627
+ return 0
628
+ else:
629
+ print(f"\033[31m\033[1m✗ {FAILED}/{PASSED + FAILED} checks failed\033[0m\n")
630
+ return 1
631
+
632
+
633
+ if __name__ == "__main__":
634
+ sys.exit(asyncio.run(run_checks()))
stk/cli/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Click commands, grouped by concern. `stk.cli.main` registers everything here."""
2
+
3
+ from stk.cli.agent import (
4
+ doctor_cmd,
5
+ inspect_cmd,
6
+ new_module,
7
+ report,
8
+ shell,
9
+ smoke,
10
+ verify,
11
+ )
12
+ from stk.cli.base import console, run_async
13
+ from stk.cli.database import (
14
+ create_db,
15
+ db,
16
+ )
17
+ from stk.cli.develop import init, run
18
+ from stk.cli.users import (
19
+ add_role,
20
+ browser_token,
21
+ cleanup_sessions,
22
+ create,
23
+ install,
24
+ protect_mfa,
25
+ reset,
26
+ )
27
+
28
+ __all__ = [
29
+ "add_role",
30
+ "browser_token",
31
+ "cleanup_sessions",
32
+ "console",
33
+ "create",
34
+ "create_db",
35
+ "db",
36
+ "doctor_cmd",
37
+ "init",
38
+ "inspect_cmd",
39
+ "install",
40
+ "new_module",
41
+ "protect_mfa",
42
+ "report",
43
+ "reset",
44
+ "run",
45
+ "run_async",
46
+ "shell",
47
+ "smoke",
48
+ "verify",
49
+ ]