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/cli/agent.py ADDED
@@ -0,0 +1,267 @@
1
+ """Agent-facing commands: inspect, verify, smoke, report, shell, scaffold."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from stk.cli.base import cli_app, console
12
+ from stk.cli.reports import (
13
+ build_commands_report,
14
+ build_context_report,
15
+ build_project_report_html,
16
+ build_routes_report,
17
+ build_verify_report,
18
+ render_commands_markdown,
19
+ )
20
+ from stk.cli.smoke import print_smoke_report, run_smoke, smoke_exit_code
21
+
22
+
23
+ @click.group(name="inspect")
24
+ def inspect_cmd():
25
+ """Inspect app structure for agents and tooling."""
26
+
27
+
28
+ @inspect_cmd.command("routes")
29
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
30
+ def inspect_routes(as_json):
31
+ """Inspect registered routes."""
32
+ report = build_routes_report(cli_app())
33
+ if as_json:
34
+ click.echo(json.dumps(report, indent=2))
35
+ return
36
+
37
+ for route in report:
38
+ methods = ",".join(route["methods"])
39
+ click.echo(f"{methods:12} {route['rule']} -> {route['endpoint']}")
40
+
41
+
42
+ @inspect_cmd.command("context")
43
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
44
+ def inspect_context(as_json):
45
+ """Inspect routes and models as one agent-readable contract."""
46
+ report = build_context_report(cli_app())
47
+ if as_json:
48
+ click.echo(json.dumps(report, indent=2))
49
+ return
50
+
51
+ click.echo(
52
+ f"Context: {len(report['routes'])} routes, {len(report['models'])} models"
53
+ )
54
+
55
+
56
+ @inspect_cmd.command("commands")
57
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
58
+ @click.option("--markdown", is_flag=True, help="Emit the docs CLI reference page.")
59
+ def inspect_commands(as_json, markdown):
60
+ """Inspect every stk command and its options."""
61
+ report = build_commands_report(click.get_current_context().find_root())
62
+ if as_json:
63
+ click.echo(json.dumps(report, indent=2))
64
+ elif markdown:
65
+ click.echo(render_commands_markdown(report), nl=False)
66
+ else:
67
+ for command in report:
68
+ click.echo(f"{command['usage']:48} {command['help']}")
69
+
70
+
71
+ @click.command()
72
+ @click.option("-c", "--command", "source", default=None, help="Run code and exit.")
73
+ def shell(source):
74
+ """Async REPL with the app, a live DB session, and all models loaded."""
75
+ from stk.shell import run_shell
76
+
77
+ run_shell(cli_app(), source)
78
+
79
+
80
+ MARKERS = {"passed": "[green]✓[/]", "failed": "[red]✗[/]", "skipped": "[yellow]-[/]"}
81
+
82
+
83
+ def _print_verify_report(report, detail_lines=5):
84
+ for check in report["checks"]:
85
+ console.print(f"{MARKERS[check['status']]} {check['name']}", highlight=False)
86
+ if check["status"] != "failed":
87
+ continue
88
+ output = check["stdout"].strip() or check["stderr"].strip()
89
+ for line in output.splitlines()[-detail_lines:]:
90
+ console.print(f" [dim]{line}[/]", highlight=False)
91
+ if check["remedy"]:
92
+ console.print(f" [cyan]→ {check['remedy']}[/]", highlight=False)
93
+
94
+
95
+ @click.command()
96
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
97
+ @click.option(
98
+ "-w", "--watch", is_flag=True, help="Re-run affected checks on every file save."
99
+ )
100
+ def verify(as_json, watch):
101
+ """Run STK verification checks."""
102
+ if watch:
103
+ _watch_and_verify()
104
+ return
105
+
106
+ report = build_verify_report()
107
+ if as_json:
108
+ click.echo(json.dumps(report, indent=2))
109
+ raise click.exceptions.Exit(0 if report["status"] == "passed" else 1)
110
+
111
+ _print_verify_report(report)
112
+ raise click.exceptions.Exit(0 if report["status"] == "passed" else 1)
113
+
114
+
115
+ def _watch_and_verify(interval=0.5):
116
+ """Verify now, then re-verify whatever the next edit could have broken."""
117
+ from stk.cli import watch as watcher
118
+ from stk.cli.reports import VERIFY_COMMANDS
119
+
120
+ root = Path.cwd()
121
+ console.print("[bold]stk verify --watch[/] [dim]ctrl-c to stop[/]\n")
122
+ _print_verify_report(build_verify_report())
123
+ seen = watcher.scan(root)
124
+
125
+ while True:
126
+ time.sleep(interval)
127
+ current = watcher.scan(root)
128
+ touched = watcher.changed(seen, current)
129
+ seen = current
130
+ if not touched:
131
+ continue
132
+
133
+ names = watcher.checks_for(touched)
134
+ if not names:
135
+ continue
136
+ shown = ", ".join(
137
+ str(path.relative_to(root)) if path.is_relative_to(root) else str(path)
138
+ for path in touched[:3]
139
+ )
140
+ extra = f" +{len(touched) - 3}" if len(touched) > 3 else ""
141
+ console.print(f"\n[dim]changed:[/] {shown}{extra}")
142
+ commands = [item for item in VERIFY_COMMANDS if item[0] in names]
143
+ _print_verify_report(build_verify_report(commands))
144
+
145
+
146
+ @click.command()
147
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
148
+ def smoke(as_json):
149
+ """Run a real-browser smoke check against a temporary development app."""
150
+ report = run_smoke()
151
+ if as_json:
152
+ click.echo(json.dumps(report, indent=2))
153
+ else:
154
+ print_smoke_report(report)
155
+ raise click.exceptions.Exit(smoke_exit_code(report))
156
+
157
+
158
+ # Named `doctor_cmd` because `stk.cli.doctor` is the module that backs it, and
159
+ # importing a submodule rebinds that attribute on the package.
160
+ @click.command("doctor")
161
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
162
+ def doctor_cmd(as_json):
163
+ """Report the state of this checkout, and what to run for anything wrong."""
164
+ from stk.cli.doctor import build_doctor_report
165
+
166
+ report = build_doctor_report()
167
+ if as_json:
168
+ click.echo(json.dumps(report, indent=2))
169
+ raise click.exceptions.Exit(0 if report["status"] != "fail" else 1)
170
+
171
+ marks = {"ok": "[green]✓[/]", "warn": "[yellow]![/]", "fail": "[red]✗[/]"}
172
+ for finding in report["findings"]:
173
+ console.print(
174
+ f"{marks[finding['status']]} [bold]{finding['name']:14}[/] {finding['detail']}",
175
+ highlight=False,
176
+ )
177
+ if finding["remedy"]:
178
+ console.print(f" [cyan]→ {finding['remedy']}[/]", highlight=False)
179
+
180
+ summary = {
181
+ "ok": "[green]Everything is ready.[/]",
182
+ "warn": "[yellow]Usable, with the warnings above.[/]",
183
+ "fail": "[red]Fix the failures above before building.[/]",
184
+ }
185
+ console.print(f"\n{summary[report['status']]}")
186
+ raise click.exceptions.Exit(0 if report["status"] != "fail" else 1)
187
+
188
+
189
+ @click.command()
190
+ @click.option(
191
+ "-o",
192
+ "--output",
193
+ default="docs/stk-report.html",
194
+ help="HTML output path.",
195
+ )
196
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
197
+ def report(output, as_json):
198
+ """Generate a static project report."""
199
+ routes = build_routes_report(cli_app())
200
+ verify_report = build_verify_report()
201
+ report_data = {"routes": routes, "verification": verify_report}
202
+ if as_json:
203
+ click.echo(json.dumps(report_data, indent=2))
204
+ return
205
+
206
+ html_report = build_project_report_html(routes, verify_report)
207
+ output_path = Path(output)
208
+ output_path.parent.mkdir(parents=True, exist_ok=True)
209
+ output_path.write_text(html_report)
210
+ console.print(f"[green]Project report written:[/] {output_path}")
211
+
212
+
213
+ def _run_stk(args):
214
+ result = subprocess.run([sys.executable, "-m", "stk", *args], check=False)
215
+ if result.returncode != 0:
216
+ raise click.ClickException(f"stk {' '.join(args)} failed")
217
+
218
+
219
+ @click.command("new")
220
+ @click.argument("name")
221
+ @click.option(
222
+ "--migrate/--no-migrate",
223
+ default=True,
224
+ help="Autogenerate and apply the migration for the new model.",
225
+ )
226
+ @click.option("--port", default=5000, show_default=True, help="Port used in the URL.")
227
+ def new_module(name, migrate, port):
228
+ """Scaffold a new extension module in the app, ending at a page you can open.
229
+
230
+ NAME is a singular lowercase snake_case identifier (e.g. blog_post); the
231
+ page and API use its plural (/blog_posts/).
232
+ Writes the module package and adds it to the app's extension list.
233
+ """
234
+ from stk.migrations import app_label
235
+ from stk.scaffold.generator import generate_module
236
+ from stk.scaffold.templates import plural
237
+
238
+ app = cli_app()
239
+ try:
240
+ actions = generate_module(name, app)
241
+ except (ValueError, FileExistsError, RuntimeError) as exc:
242
+ raise click.ClickException(str(exc)) from exc
243
+
244
+ console.print(f"\n[green]Scaffolded module:[/] [bold]{name}[/]")
245
+ for action in actions:
246
+ console.print(f" [blue]+[/] {action}")
247
+
248
+ if migrate:
249
+ # The app object predates the module; a fresh process imports it.
250
+ # Autogenerate compares against a database at its heads.
251
+ _run_stk(["db", "upgrade"])
252
+ _run_stk(["db", "revision", "-m", f"add {name}"])
253
+ _run_stk(["db", "upgrade"])
254
+ console.print(f" [blue]+[/] migration generated and applied for {name}")
255
+
256
+ url = f"http://localhost:{port}/{plural(name)}/"
257
+ console.print(f"\n[green]Open:[/] [bold]{url}[/] [dim](start it with: stk run)[/]")
258
+ console.print(
259
+ f"""[dim]Then:[/]
260
+ Shape the domain in [bold]{app_label(app)}/{name}/models.py[/]{
261
+ ', and re-run `stk db revision -m "..." && stk db upgrade`'
262
+ if migrate
263
+ else ', then `stk db revision -m "add ' + name + '"` and `stk db upgrade`'
264
+ }
265
+ Keep it honest with [bold]stk verify --watch[/] while you edit
266
+ """
267
+ )
stk/cli/base.py ADDED
@@ -0,0 +1,79 @@
1
+ """Shared CLI plumbing: finding the app, console output, the sync/async bridge."""
2
+
3
+ import asyncio
4
+ import importlib
5
+ import os
6
+ import sys
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.console import Console
12
+
13
+ import stk.extensions as ext
14
+
15
+ console = Console()
16
+
17
+
18
+ def project_root() -> Path:
19
+ """The directory of the nearest pyproject.toml, from the working directory up."""
20
+ cwd = Path.cwd()
21
+ for directory in (cwd, *cwd.parents):
22
+ if (directory / "pyproject.toml").is_file():
23
+ return directory
24
+ raise click.ClickException("no pyproject.toml here or above: cd into an app")
25
+
26
+
27
+ def _tool_stk() -> dict:
28
+ pyproject = project_root() / "pyproject.toml"
29
+ return tomllib.loads(pyproject.read_text()).get("tool", {}).get("stk", {})
30
+
31
+
32
+ def app_spec() -> str:
33
+ """The app as `module:attribute`, from STK_APP or `[tool.stk] app`."""
34
+ if spec := os.environ.get("STK_APP"):
35
+ return spec
36
+ if spec := _tool_stk().get("app"):
37
+ return spec
38
+ raise click.ClickException(
39
+ f'{project_root() / "pyproject.toml"} has no [tool.stk] app = "package.app:app".'
40
+ " Start one with: uv run stk init <name>"
41
+ )
42
+
43
+
44
+ def app_root() -> Path:
45
+ """The directory the app's package is imported from.
46
+
47
+ The project, unless `[tool.stk] path` names another: stk's own repository
48
+ runs its bundled example that way.
49
+ """
50
+ path = None if os.environ.get("STK_APP") else _tool_stk().get("path")
51
+ return project_root() / path if path else project_root()
52
+
53
+
54
+ def load_app():
55
+ """Import and return the app named by `app_spec()`."""
56
+ module_name, _, attribute = app_spec().partition(":")
57
+ # The project need not be installed: import it from its own directory.
58
+ sys.path.insert(0, str(app_root()))
59
+ return getattr(importlib.import_module(module_name), attribute or "app")
60
+
61
+
62
+ def cli_app():
63
+ """The app for this CLI invocation, loaded once."""
64
+ from quart.cli import ScriptInfo
65
+
66
+ return click.get_current_context().ensure_object(ScriptInfo).load_app()
67
+
68
+
69
+ def run_async(coro):
70
+ """Run an async function in sync context (for CLI commands)."""
71
+
72
+ async def _wrapper():
73
+ try:
74
+ return await coro
75
+ finally:
76
+ if ext.engine:
77
+ await ext.engine.dispose()
78
+
79
+ return asyncio.run(_wrapper())
stk/cli/database.py ADDED
@@ -0,0 +1,199 @@
1
+ """Database and migration commands."""
2
+
3
+ import click
4
+ from alembic import command
5
+
6
+ from stk.cli.base import cli_app, console
7
+ from stk.migrations import (
8
+ CORE_LABEL,
9
+ app_label,
10
+ app_versions,
11
+ build_alembic_config,
12
+ get_target_metadata,
13
+ throwaway_database_url,
14
+ )
15
+
16
+
17
+ def run_alembic(action, *args, **kwargs):
18
+ """Run an alembic command, reporting its complaint instead of a traceback."""
19
+ from alembic.util.exc import CommandError
20
+
21
+ try:
22
+ return action(*args, **kwargs)
23
+ except CommandError as exc:
24
+ raise click.ClickException(f"{exc}\nrun: uv run stk doctor") from exc
25
+
26
+
27
+ def _flatten(diffs):
28
+ for diff in diffs:
29
+ if isinstance(diff, list):
30
+ yield from diff
31
+ else:
32
+ yield diff
33
+
34
+
35
+ def _describe_diff(diff):
36
+ """Render an autogenerate diff tuple as one readable line."""
37
+ parts = []
38
+ for element in diff[1:]:
39
+ if element is None or isinstance(element, dict): # schema, autogenerate opts
40
+ continue
41
+ parts.append(str(getattr(element, "name", element)))
42
+ return f"{diff[0]}: {' '.join(parts)}".strip()
43
+
44
+
45
+ def build_migration_drift_report(app=None, database_url=None):
46
+ """Return differences between the migrations at their heads and the models.
47
+
48
+ Migrations run against a throwaway database of the app's dialect, so the
49
+ answer does not depend on the state of any developer machine. With no app,
50
+ stk's own lineage is checked against its own models.
51
+ """
52
+ from alembic.autogenerate import compare_metadata
53
+ from alembic.runtime.migration import MigrationContext
54
+ from sqlalchemy import create_engine
55
+
56
+ from stk.migrations import include_object
57
+
58
+ if database_url is None:
59
+ database_url = app.config["SQLALCHEMY_DATABASE_URI"]
60
+ with throwaway_database_url(database_url, sync=True) as url:
61
+ config = build_alembic_config(app, url, quiet=True)
62
+ command.upgrade(config, "heads")
63
+
64
+ engine = create_engine(url)
65
+ try:
66
+ with engine.connect() as connection:
67
+ context = MigrationContext.configure(
68
+ connection,
69
+ opts={
70
+ "compare_type": True,
71
+ "compare_server_default": True,
72
+ "include_object": include_object,
73
+ },
74
+ )
75
+ diffs = compare_metadata(context, get_target_metadata())
76
+ finally:
77
+ engine.dispose()
78
+
79
+ return [_describe_diff(diff) for diff in _flatten(diffs)]
80
+
81
+
82
+ def generate_revision(app, message, *, autogenerate=True, head=None, quiet=False):
83
+ """Write a revision in the app's lineage, or in `head`'s when given.
84
+
85
+ The app's first revision starts its branch. Every app revision depends on
86
+ stk's head at the time it is written, so core tables (`user`) exist first.
87
+ """
88
+ from alembic.script import ScriptDirectory
89
+
90
+ config = build_alembic_config(app, quiet=quiet)
91
+ if head is not None:
92
+ return run_alembic(
93
+ command.revision,
94
+ config,
95
+ message=message,
96
+ autogenerate=autogenerate,
97
+ head=head,
98
+ )
99
+ script = ScriptDirectory.from_config(config)
100
+ label = app_label(app)
101
+ started = any(label in rev.branch_labels for rev in script.walk_revisions())
102
+ core_head = script.get_revision(f"{CORE_LABEL}@head").revision
103
+ options = (
104
+ {"head": f"{label}@head"}
105
+ if started
106
+ else {
107
+ "head": "base",
108
+ "branch_label": label,
109
+ "version_path": str(app_versions(app)),
110
+ }
111
+ )
112
+ app_versions(app).mkdir(exist_ok=True)
113
+ return run_alembic(
114
+ command.revision,
115
+ config,
116
+ message=message,
117
+ autogenerate=autogenerate,
118
+ depends_on=core_head,
119
+ **options,
120
+ )
121
+
122
+
123
+ @click.command()
124
+ def create_db():
125
+ """Apply all database migrations (same as `stk db upgrade`)."""
126
+ run_alembic(command.upgrade, build_alembic_config(cli_app()), "heads")
127
+ console.print("[green]Database migrations applied successfully[/]")
128
+
129
+
130
+ @click.group()
131
+ def db():
132
+ """Alembic-backed database migration commands."""
133
+
134
+
135
+ @db.command("upgrade")
136
+ @click.argument("revision", default="heads")
137
+ def db_upgrade(revision):
138
+ """Upgrade the database: stk's migrations and the app's, to their heads."""
139
+ run_alembic(command.upgrade, build_alembic_config(cli_app()), revision)
140
+
141
+
142
+ # Relative targets like "-1" look like options to click; accept them as the argument.
143
+ @db.command("downgrade", context_settings={"ignore_unknown_options": True})
144
+ @click.argument("revision")
145
+ def db_downgrade(revision):
146
+ """Downgrade the database to a target revision."""
147
+ run_alembic(command.downgrade, build_alembic_config(cli_app()), revision)
148
+
149
+
150
+ @db.command("revision")
151
+ @click.option("-m", "--message", required=True, help="Revision message")
152
+ @click.option(
153
+ "--autogenerate/--empty",
154
+ default=True,
155
+ help="Autogenerate from model metadata or create an empty revision",
156
+ )
157
+ @click.option(
158
+ "--head",
159
+ default=None,
160
+ help="Extend this head instead of the app's lineage (stk@head for core).",
161
+ )
162
+ def db_revision(message, autogenerate, head):
163
+ """Create a migration revision in the app's own lineage."""
164
+ generate_revision(cli_app(), message, autogenerate=autogenerate, head=head)
165
+
166
+
167
+ @db.command("check")
168
+ def db_check():
169
+ """Fail if models have drifted away from the migrations."""
170
+ drift = build_migration_drift_report(cli_app())
171
+ if not drift:
172
+ console.print("[green]No migration drift: models match migrations[/]")
173
+ return
174
+
175
+ console.print("[red]Migration drift detected:[/]")
176
+ for line in drift:
177
+ console.print(f" [yellow]{line}[/]")
178
+ raise click.ClickException(
179
+ 'run: uv run stk db revision -m "describe change" && uv run stk db upgrade'
180
+ )
181
+
182
+
183
+ @db.command("current")
184
+ def db_current():
185
+ """Show the current database revision."""
186
+ run_alembic(command.current, build_alembic_config(cli_app()))
187
+
188
+
189
+ @db.command("history")
190
+ def db_history():
191
+ """Show migration history."""
192
+ run_alembic(command.history, build_alembic_config(cli_app()))
193
+
194
+
195
+ @db.command("stamp")
196
+ @click.argument("revision", default="heads")
197
+ def db_stamp(revision):
198
+ """Stamp a database with a revision without running migrations."""
199
+ run_alembic(command.stamp, build_alembic_config(cli_app()), revision)
stk/cli/develop.py ADDED
@@ -0,0 +1,63 @@
1
+ """Starting an app and serving it locally."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ import stk
8
+ from stk.cli.base import app_root, app_spec, cli_app, console
9
+
10
+
11
+ @click.command("init")
12
+ @click.argument("name")
13
+ @click.option(
14
+ "--source",
15
+ default=None,
16
+ help="Install stk from this checkout or wheel instead of the stkit package.",
17
+ )
18
+ def init(name, source):
19
+ """Start a new app package NAME in ./NAME, depending on this stk version."""
20
+ from stk.scaffold.generator import generate_app
21
+
22
+ try:
23
+ actions = generate_app(name, Path.cwd() / name, source)
24
+ except (ValueError, FileExistsError) as exc:
25
+ raise click.ClickException(str(exc)) from exc
26
+
27
+ for action in actions:
28
+ console.print(f" [blue]+[/] {action}")
29
+ console.print(
30
+ f"""
31
+ [green]App {name} is ready.[/] Next:
32
+ cd {name}
33
+ uv run stk db upgrade && uv run stk install
34
+ uv run stk run
35
+ """
36
+ )
37
+
38
+
39
+ @click.command("run")
40
+ @click.option("--host", default="127.0.0.1", help="The interface to bind to.")
41
+ @click.option("-p", "--port", default=5000, help="The port to bind to.")
42
+ @click.option(
43
+ "--reload/--no-reload",
44
+ default=None,
45
+ help="Restart on code changes (default: on when QUART_DEBUG is set).",
46
+ )
47
+ def run(host, port, reload):
48
+ """Run a local development server (uvicorn, as in production)."""
49
+ import uvicorn
50
+ from quart.helpers import get_debug_flag
51
+
52
+ app = cli_app()
53
+ # Hypercorn, Quart's own runner, waits forever on open tabs at Ctrl+C. The
54
+ # timeout ends long-lived streams (/ws, SSE); browsers reconnect.
55
+ uvicorn.run(
56
+ app_spec(),
57
+ host=host,
58
+ port=port,
59
+ reload=get_debug_flag() if reload is None else reload,
60
+ reload_dirs=[app.root_path, str(Path(stk.__file__).parent)],
61
+ app_dir=str(app_root()),
62
+ timeout_graceful_shutdown=3,
63
+ )