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.
- stk/__init__.py +19 -0
- stk/__main__.py +6 -0
- stk/agent_login.py +78 -0
- stk/app.py +323 -0
- stk/checks.py +634 -0
- stk/cli/__init__.py +49 -0
- stk/cli/agent.py +267 -0
- stk/cli/base.py +79 -0
- stk/cli/database.py +199 -0
- stk/cli/develop.py +63 -0
- stk/cli/doctor.py +262 -0
- stk/cli/main.py +101 -0
- stk/cli/reports.py +296 -0
- stk/cli/smoke.py +399 -0
- stk/cli/users.py +259 -0
- stk/cli/watch.py +68 -0
- stk/extensions.py +29 -0
- stk/migrations/__init__.py +130 -0
- stk/migrations/env.py +58 -0
- stk/migrations/script.py.mako +17 -0
- stk/migrations/versions/stk_0001_initial_schema.py +213 -0
- stk/portal/views.py +34 -0
- stk/public/__init__.py +0 -0
- stk/public/models.py +0 -0
- stk/public/views.py +344 -0
- stk/scaffold/__init__.py +1 -0
- stk/scaffold/app/CLAUDE.md +65 -0
- stk/scaffold/app/Dockerfile +41 -0
- stk/scaffold/app/README.md +16 -0
- stk/scaffold/app/__app__/__init__.py +1 -0
- stk/scaffold/app/__app__/app.py +8 -0
- stk/scaffold/app/__app__/settings.py +7 -0
- stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
- stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
- stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
- stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
- stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
- stk/scaffold/app/docker-compose.yml +88 -0
- stk/scaffold/app/dockerignore +13 -0
- stk/scaffold/app/env-sample +78 -0
- stk/scaffold/app/gitignore +8 -0
- stk/scaffold/app/nginx/nginx.conf +46 -0
- stk/scaffold/app/nginx/stk.conf +36 -0
- stk/scaffold/app/postgres/init-db.sh +8 -0
- stk/scaffold/app/pyproject.toml +23 -0
- stk/scaffold/app/tests/__init__.py +0 -0
- stk/scaffold/app/tests/test_app.py +23 -0
- stk/scaffold/generator.py +209 -0
- stk/scaffold/templates.py +437 -0
- stk/security.py +145 -0
- stk/settings.py +147 -0
- stk/shell.py +284 -0
- stk/static/VERSIONS.txt +4 -0
- stk/static/css/app.css +229 -0
- stk/static/css/layout.css +330 -0
- stk/static/css/vuetify.min.css +6 -0
- stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- stk/static/icons/tabler-icons.min.css +4 -0
- stk/static/img/auth-bg.webp +0 -0
- stk/static/img/favicon.ico +0 -0
- stk/static/img/stk.svg +1664 -0
- stk/static/js/axios.min.js +5 -0
- stk/static/js/components/NotificationDropdown.js +156 -0
- stk/static/js/components/ThemeSwitcher.js +68 -0
- stk/static/js/components/TransitionExpand.js +64 -0
- stk/static/js/components/VerticalNavGroup.js +119 -0
- stk/static/js/components/VerticalNavLink.js +36 -0
- stk/static/js/components/VerticalNavSectionTitle.js +21 -0
- stk/static/js/components/index.js +47 -0
- stk/static/js/config.js +150 -0
- stk/static/js/vue.min.js +13 -0
- stk/static/js/vuetify.min.js +3148 -0
- stk/static/robots.txt +1 -0
- stk/tasks/__init__.py +78 -0
- stk/templates/401.html +14 -0
- stk/templates/404.html +12 -0
- stk/templates/500.html +13 -0
- stk/templates/_brand.html +1 -0
- stk/templates/auth_layout.html +26 -0
- stk/templates/cms/activities.html +135 -0
- stk/templates/cms/roles.html +215 -0
- stk/templates/cms/users.html +312 -0
- stk/templates/dashboard.html +132 -0
- stk/templates/index.html +52 -0
- stk/templates/layout.html +328 -0
- stk/templates/login-layout.html +54 -0
- stk/templates/security/_macros.html +39 -0
- stk/templates/security/_messages.html +12 -0
- stk/templates/security/change_password.html +100 -0
- stk/templates/security/confirm_email.html +44 -0
- stk/templates/security/email/change_notice.html +4 -0
- stk/templates/security/email/change_notice.txt +5 -0
- stk/templates/security/email/confirmation_instructions.html +3 -0
- stk/templates/security/email/confirmation_instructions.txt +3 -0
- stk/templates/security/email/login_instructions.html +5 -0
- stk/templates/security/email/login_instructions.txt +5 -0
- stk/templates/security/email/reset_instructions.html +1 -0
- stk/templates/security/email/reset_instructions.txt +3 -0
- stk/templates/security/email/reset_notice.html +1 -0
- stk/templates/security/email/reset_notice.txt +1 -0
- stk/templates/security/email/welcome.html +7 -0
- stk/templates/security/email/welcome.txt +7 -0
- stk/templates/security/forgot_password.html +63 -0
- stk/templates/security/login_user.html +206 -0
- stk/templates/security/mf_recovery.html +67 -0
- stk/templates/security/mf_recovery_codes.html +119 -0
- stk/templates/security/register_user.html +263 -0
- stk/templates/security/reset_password.html +53 -0
- stk/templates/security/send_confirmation.html +48 -0
- stk/templates/security/send_login.html +39 -0
- stk/templates/security/two_factor_select.html +78 -0
- stk/templates/security/two_factor_setup.html +166 -0
- stk/templates/security/two_factor_verify_code.html +71 -0
- stk/templates/security/wan_register.html +179 -0
- stk/templates/security/wan_signin.html +123 -0
- stk/templates/security/wan_verify.html +109 -0
- stk/user/__init__.py +0 -0
- stk/user/forms.py +10 -0
- stk/user/models.py +308 -0
- stk/user/views.py +339 -0
- stk/utils/email.py +32 -0
- stk/utils/ratelimit.py +67 -0
- stk/websocket.py +133 -0
- stkit-17.0.0.dist-info/METADATA +135 -0
- stkit-17.0.0.dist-info/RECORD +128 -0
- stkit-17.0.0.dist-info/WHEEL +4 -0
- stkit-17.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|