skrift 0.1.0a1__tar.gz → 0.1.0a5__tar.gz

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 (75) hide show
  1. {skrift-0.1.0a1 → skrift-0.1.0a5}/.gitignore +3 -0
  2. {skrift-0.1.0a1 → skrift-0.1.0a5}/PKG-INFO +3 -1
  3. {skrift-0.1.0a1 → skrift-0.1.0a5}/pyproject.toml +10 -3
  4. skrift-0.1.0a5/skrift/__main__.py +12 -0
  5. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic/env.py +2 -1
  6. skrift-0.1.0a5/skrift/alembic/versions/20260129_add_oauth_accounts.py +134 -0
  7. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic.ini +2 -2
  8. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/asgi.py +19 -11
  9. skrift-0.1.0a5/skrift/cli.py +143 -0
  10. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/config.py +61 -7
  11. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/controllers/auth.py +168 -22
  12. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/models/__init__.py +2 -1
  13. skrift-0.1.0a5/skrift/db/models/oauth_account.py +37 -0
  14. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/models/user.py +5 -5
  15. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/setup/config_writer.py +4 -2
  16. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/setup/controller.py +209 -72
  17. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/setup/providers.py +53 -2
  18. skrift-0.1.0a5/skrift/setup/state.py +315 -0
  19. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/static/css/style.css +3 -3
  20. skrift-0.1.0a5/skrift/templates/auth/dummy_login.html +102 -0
  21. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/auth/login.html +14 -0
  22. skrift-0.1.0a5/skrift/templates/setup/configuring.html +158 -0
  23. skrift-0.1.0a1/skrift/__main__.py +0 -17
  24. skrift-0.1.0a1/skrift/cli.py +0 -45
  25. skrift-0.1.0a1/skrift/setup/state.py +0 -134
  26. {skrift-0.1.0a1 → skrift-0.1.0a5}/README.md +0 -0
  27. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/__init__.py +0 -0
  28. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/admin/__init__.py +0 -0
  29. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/admin/controller.py +0 -0
  30. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/admin/navigation.py +0 -0
  31. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic/script.py.mako +0 -0
  32. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
  33. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
  34. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
  35. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
  36. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
  37. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/auth/__init__.py +0 -0
  38. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/auth/guards.py +0 -0
  39. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/auth/roles.py +0 -0
  40. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/auth/services.py +0 -0
  41. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/controllers/__init__.py +0 -0
  42. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/controllers/web.py +0 -0
  43. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/__init__.py +0 -0
  44. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/base.py +0 -0
  45. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/models/page.py +0 -0
  46. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/models/role.py +0 -0
  47. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/models/setting.py +0 -0
  48. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/services/__init__.py +0 -0
  49. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/services/page_service.py +0 -0
  50. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/db/services/setting_service.py +0 -0
  51. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/lib/__init__.py +0 -0
  52. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/lib/exceptions.py +0 -0
  53. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/lib/template.py +0 -0
  54. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/setup/__init__.py +0 -0
  55. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/setup/middleware.py +0 -0
  56. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/admin/admin.html +0 -0
  57. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/admin/base.html +0 -0
  58. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/admin/pages/edit.html +0 -0
  59. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/admin/pages/list.html +0 -0
  60. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/admin/settings/site.html +0 -0
  61. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/admin/users/list.html +0 -0
  62. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/admin/users/roles.html +0 -0
  63. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/base.html +0 -0
  64. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/error-404.html +0 -0
  65. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/error-500.html +0 -0
  66. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/error.html +0 -0
  67. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/index.html +0 -0
  68. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/page.html +0 -0
  69. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/setup/admin.html +0 -0
  70. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/setup/auth.html +0 -0
  71. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/setup/base.html +0 -0
  72. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/setup/complete.html +0 -0
  73. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/setup/database.html +0 -0
  74. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/setup/restart.html +0 -0
  75. {skrift-0.1.0a1 → skrift-0.1.0a5}/skrift/templates/setup/site.html +0 -0
@@ -20,5 +20,8 @@ wheels/
20
20
 
21
21
  # App configuration (generated by setup wizard)
22
22
  app.yaml
23
+ app.*.yaml
23
24
  app.bak.yaml
24
25
  app.yaml.backup.*
26
+ app.*.yaml.backup.*
27
+ site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a1
3
+ Version: 0.1.0a5
4
4
  Summary: A lightweight async Python CMS for crafting modern websites
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: advanced-alchemy>=0.26.0
@@ -15,6 +15,8 @@ Requires-Dist: pyyaml>=6.0.0
15
15
  Requires-Dist: ruamel-yaml>=0.18.0
16
16
  Requires-Dist: sqlalchemy[asyncio]>=2.0.36
17
17
  Requires-Dist: uvicorn>=0.34.0
18
+ Provides-Extra: docs
19
+ Requires-Dist: zensical>=0.0.19; extra == 'docs'
18
20
  Description-Content-Type: text/markdown
19
21
 
20
22
  # Skrift
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skrift"
3
- version = "0.1.0a1"
3
+ version = "0.1.0a5"
4
4
  description = "A lightweight async Python CMS for crafting modern websites"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -20,8 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  [project.scripts]
23
- skrift = "skrift.__main__:main"
24
- skrift-db = "skrift.cli:db"
23
+ skrift = "skrift.cli:cli"
25
24
 
26
25
  [tool.uv]
27
26
  package = true
@@ -30,6 +29,11 @@ package = true
30
29
  requires = ["hatchling"]
31
30
  build-backend = "hatchling.build"
32
31
 
32
+ [dependency-groups]
33
+ docs = [
34
+ "zensical>=0.0.19",
35
+ ]
36
+
33
37
  [tool.hatch.build]
34
38
  include = [
35
39
  "skrift/**/*.py",
@@ -45,6 +49,9 @@ exclude = [
45
49
  "*.db",
46
50
  ]
47
51
 
52
+ [project.optional-dependencies]
53
+ docs = ["zensical>=0.0.19"]
54
+
48
55
  [tool.ruff]
49
56
  line-length = 100
50
57
  target-version = "py313"
@@ -0,0 +1,12 @@
1
+ """Entry point for the skrift package."""
2
+
3
+ from skrift.cli import cli
4
+
5
+
6
+ def main():
7
+ """Run the Skrift CLI."""
8
+ cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -12,6 +12,7 @@ from skrift.config import get_settings
12
12
  from skrift.db.base import Base
13
13
 
14
14
  # Import all models to ensure they're registered with Base.metadata
15
+ from skrift.db.models.oauth_account import OAuthAccount # noqa: F401
15
16
  from skrift.db.models.user import User # noqa: F401
16
17
  from skrift.db.models.page import Page # noqa: F401
17
18
  from skrift.db.models.role import Role, RolePermission # noqa: F401
@@ -31,7 +32,7 @@ def get_url() -> str:
31
32
  """Get database URL from settings or alembic.ini."""
32
33
  try:
33
34
  settings = get_settings()
34
- return settings.database_url
35
+ return settings.db.url
35
36
  except Exception:
36
37
  # Fall back to alembic.ini config if settings can't be loaded
37
38
  return config.get_main_option("sqlalchemy.url", "")
@@ -0,0 +1,134 @@
1
+ """add oauth_accounts table
2
+
3
+ Revision ID: 1a2b3c4d5e6f
4
+ Revises: 8f3a5c2d1e0b
5
+ Create Date: 2026-01-29 10:00:00.000000
6
+
7
+ This migration:
8
+ 1. Creates the oauth_accounts table to store multiple OAuth identities per user
9
+ 2. Migrates existing oauth data from users table to oauth_accounts
10
+ 3. Makes users.email nullable (for providers like Twitter that don't provide email)
11
+ 4. Removes oauth_provider and oauth_id columns from users table
12
+ """
13
+ from typing import Sequence, Union
14
+
15
+ from alembic import op
16
+ import sqlalchemy as sa
17
+ from advanced_alchemy.types import GUID, DateTimeUTC
18
+
19
+
20
+ # revision identifiers, used by Alembic.
21
+ revision: str = '1a2b3c4d5e6f'
22
+ down_revision: Union[str, None] = '8f3a5c2d1e0b'
23
+ branch_labels: Union[str, Sequence[str], None] = None
24
+ depends_on: Union[str, Sequence[str], None] = None
25
+
26
+
27
+ def upgrade() -> None:
28
+ # Step 1: Create oauth_accounts table
29
+ op.create_table(
30
+ 'oauth_accounts',
31
+ sa.Column('id', GUID(length=16), nullable=False),
32
+ sa.Column('created_at', DateTimeUTC(timezone=True), nullable=False),
33
+ sa.Column('updated_at', DateTimeUTC(timezone=True), nullable=False),
34
+ sa.Column('sa_orm_sentinel', sa.Integer(), nullable=True),
35
+ sa.Column('provider', sa.String(length=50), nullable=False),
36
+ sa.Column('provider_account_id', sa.String(length=255), nullable=False),
37
+ sa.Column('provider_email', sa.String(length=255), nullable=True),
38
+ sa.Column('user_id', GUID(length=16), nullable=False),
39
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth_accounts')),
40
+ sa.ForeignKeyConstraint(
41
+ ['user_id'], ['users.id'],
42
+ name=op.f('fk_oauth_accounts_user_id_users'),
43
+ ondelete='CASCADE'
44
+ ),
45
+ sa.UniqueConstraint(
46
+ 'provider', 'provider_account_id',
47
+ name='uq_oauth_provider_account'
48
+ ),
49
+ )
50
+ op.create_index(
51
+ op.f('ix_oauth_accounts_user_id'),
52
+ 'oauth_accounts',
53
+ ['user_id'],
54
+ unique=False
55
+ )
56
+ op.create_index(
57
+ op.f('ix_oauth_accounts_provider_account'),
58
+ 'oauth_accounts',
59
+ ['provider', 'provider_account_id'],
60
+ unique=True
61
+ )
62
+
63
+ # Step 2: Migrate existing data from users to oauth_accounts
64
+ # Generate binary UUIDs (16 bytes) for new records and copy oauth data
65
+ conn = op.get_bind()
66
+ conn.execute(sa.text("""
67
+ INSERT INTO oauth_accounts (id, created_at, updated_at, provider, provider_account_id, provider_email, user_id)
68
+ SELECT
69
+ randomblob(16),
70
+ created_at,
71
+ updated_at,
72
+ oauth_provider,
73
+ oauth_id,
74
+ email,
75
+ id
76
+ FROM users
77
+ WHERE oauth_provider IS NOT NULL AND oauth_id IS NOT NULL
78
+ """))
79
+
80
+ # Step 3: Make email nullable on users table
81
+ # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
82
+ # For SQLite, we'll use batch_alter_table
83
+ with op.batch_alter_table('users', schema=None) as batch_op:
84
+ # Drop the unique constraint on oauth_id
85
+ batch_op.drop_constraint('uq_users_oauth_id', type_='unique')
86
+ # Drop the oauth columns
87
+ batch_op.drop_column('oauth_provider')
88
+ batch_op.drop_column('oauth_id')
89
+ # Make email nullable - this requires recreating the column in SQLite
90
+ batch_op.alter_column('email',
91
+ existing_type=sa.String(length=255),
92
+ nullable=True)
93
+
94
+
95
+ def downgrade() -> None:
96
+ # Step 1: Add back oauth columns to users table
97
+ with op.batch_alter_table('users', schema=None) as batch_op:
98
+ batch_op.add_column(sa.Column('oauth_provider', sa.String(length=50), nullable=True))
99
+ batch_op.add_column(sa.Column('oauth_id', sa.String(length=255), nullable=True))
100
+ batch_op.alter_column('email',
101
+ existing_type=sa.String(length=255),
102
+ nullable=False)
103
+
104
+ # Step 2: Migrate data back from oauth_accounts to users
105
+ # Only migrate the first oauth account per user
106
+ conn = op.get_bind()
107
+ conn.execute(sa.text("""
108
+ UPDATE users
109
+ SET oauth_provider = (
110
+ SELECT provider FROM oauth_accounts
111
+ WHERE oauth_accounts.user_id = users.id
112
+ LIMIT 1
113
+ ),
114
+ oauth_id = (
115
+ SELECT provider_account_id FROM oauth_accounts
116
+ WHERE oauth_accounts.user_id = users.id
117
+ LIMIT 1
118
+ )
119
+ """))
120
+
121
+ # Step 3: Make oauth columns non-nullable and add unique constraint
122
+ with op.batch_alter_table('users', schema=None) as batch_op:
123
+ batch_op.alter_column('oauth_provider',
124
+ existing_type=sa.String(length=50),
125
+ nullable=False)
126
+ batch_op.alter_column('oauth_id',
127
+ existing_type=sa.String(length=255),
128
+ nullable=False)
129
+ batch_op.create_unique_constraint('uq_users_oauth_id', ['oauth_id'])
130
+
131
+ # Step 4: Drop oauth_accounts table
132
+ op.drop_index(op.f('ix_oauth_accounts_provider_account'), table_name='oauth_accounts')
133
+ op.drop_index(op.f('ix_oauth_accounts_user_id'), table_name='oauth_accounts')
134
+ op.drop_table('oauth_accounts')
@@ -1,8 +1,8 @@
1
1
  # Alembic Configuration File
2
2
 
3
3
  [alembic]
4
- # Path to migration scripts
5
- script_location = alembic
4
+ # Path to migration scripts (relative to this file)
5
+ script_location = %(here)s/alembic
6
6
 
7
7
  # Template used to generate migration files
8
8
  file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
@@ -29,7 +29,7 @@ from litestar.static_files import create_static_files_router
29
29
  from litestar.template import TemplateConfig
30
30
  from litestar.types import ASGIApp, Receive, Scope, Send
31
31
 
32
- from skrift.config import get_settings, is_config_valid
32
+ from skrift.config import get_config_path, get_settings, is_config_valid
33
33
  from skrift.db.base import Base
34
34
  from skrift.db.services.setting_service import (
35
35
  load_site_settings_cache,
@@ -45,7 +45,7 @@ from skrift.lib.exceptions import http_exception_handler, internal_server_error_
45
45
 
46
46
  def load_controllers() -> list:
47
47
  """Load controllers from app.yaml configuration."""
48
- config_path = Path.cwd() / "app.yaml"
48
+ config_path = get_config_path()
49
49
 
50
50
  if not config_path.exists():
51
51
  return []
@@ -196,12 +196,7 @@ class AppDispatcher:
196
196
  await self.setup_app(scope, receive, send)
197
197
  return
198
198
 
199
- # For /auth/* during setup, route to setup app (OAuth callbacks)
200
- if path.startswith("/auth"):
201
- await self.setup_app(scope, receive, send)
202
- return
203
-
204
- # Non-setup path: check if setup is complete in DB
199
+ # Check if setup is complete in DB
205
200
  if await self._is_setup_complete_in_db():
206
201
  # Setup complete - try to get/create main app
207
202
  main_app = await self._get_or_create_main_app()
@@ -215,8 +210,13 @@ class AppDispatcher:
215
210
  f"Setup complete but cannot start application: {self._main_app_error}"
216
211
  )
217
212
  else:
218
- # Setup not complete - redirect to /setup
219
- await self._redirect(send, "/setup")
213
+ # Setup not complete
214
+ # Route /auth/* to setup app for OAuth callbacks during setup
215
+ if path.startswith("/auth"):
216
+ await self.setup_app(scope, receive, send)
217
+ else:
218
+ # Redirect other paths to /setup
219
+ await self._redirect(send, "/setup")
220
220
 
221
221
  async def _is_setup_complete_in_db(self) -> bool:
222
222
  """Check if setup is complete in the database."""
@@ -270,6 +270,10 @@ def create_app() -> Litestar:
270
270
  This app has all routes for normal operation. It is used by the dispatcher
271
271
  after setup is complete.
272
272
  """
273
+ # CRITICAL: Check for dummy auth in production BEFORE anything else
274
+ from skrift.setup.providers import validate_no_dummy_auth_in_production
275
+ validate_no_dummy_auth_in_production()
276
+
273
277
  settings = get_settings()
274
278
 
275
279
  # Load controllers from app.yaml
@@ -404,7 +408,7 @@ def create_setup_app() -> Litestar:
404
408
 
405
409
  # Also try to get the raw db URL from config (before env var resolution)
406
410
  if not db_url:
407
- config_path = Path.cwd() / "app.yaml"
411
+ config_path = get_config_path()
408
412
  if config_path.exists():
409
413
  try:
410
414
  with open(config_path, "r") as f:
@@ -493,6 +497,10 @@ def create_dispatcher() -> ASGIApp:
493
497
  This is the main entry point. The dispatcher handles routing between
494
498
  setup and main apps, with lazy creation of the main app after setup completes.
495
499
  """
500
+ # CRITICAL: Check for dummy auth in production BEFORE anything else
501
+ from skrift.setup.providers import validate_no_dummy_auth_in_production
502
+ validate_no_dummy_auth_in_production()
503
+
496
504
  global _dispatcher
497
505
  from skrift.setup.state import get_database_url_from_yaml
498
506
 
@@ -0,0 +1,143 @@
1
+ """CLI commands for Skrift."""
2
+
3
+ import base64
4
+ import os
5
+ import re
6
+ import secrets
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(package_name="skrift")
15
+ def cli():
16
+ """Skrift - A lightweight async Python CMS."""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @click.option("--host", default="127.0.0.1", help="Host to bind to")
22
+ @click.option("--port", default=8080, type=int, help="Port to bind to")
23
+ @click.option("--reload", is_flag=True, help="Enable auto-reload for development")
24
+ @click.option("--workers", default=1, type=int, help="Number of worker processes")
25
+ @click.option(
26
+ "--log-level",
27
+ default="info",
28
+ type=click.Choice(["debug", "info", "warning", "error"]),
29
+ help="Logging level",
30
+ )
31
+ def serve(host, port, reload, workers, log_level):
32
+ """Run the Skrift server."""
33
+ import uvicorn
34
+
35
+ uvicorn.run(
36
+ "skrift.asgi:app",
37
+ host=host,
38
+ port=port,
39
+ reload=reload,
40
+ workers=workers if not reload else 1,
41
+ log_level=log_level,
42
+ )
43
+
44
+
45
+ @cli.command()
46
+ @click.option(
47
+ "--write",
48
+ type=click.Path(),
49
+ default=None,
50
+ help="Write SECRET_KEY to a .env file",
51
+ )
52
+ @click.option(
53
+ "--format",
54
+ "fmt",
55
+ default="urlsafe",
56
+ type=click.Choice(["urlsafe", "hex", "base64"]),
57
+ help="Output format for the secret key",
58
+ )
59
+ @click.option("--length", default=32, type=int, help="Number of random bytes")
60
+ def secret(write, fmt, length):
61
+ """Generate a secure secret key."""
62
+ # Generate key based on format
63
+ if fmt == "urlsafe":
64
+ key = secrets.token_urlsafe(length)
65
+ elif fmt == "hex":
66
+ key = secrets.token_hex(length)
67
+ else: # base64
68
+ key = base64.b64encode(secrets.token_bytes(length)).decode("ascii")
69
+
70
+ if write:
71
+ env_path = Path(write)
72
+ env_content = ""
73
+
74
+ # Read existing content if file exists
75
+ if env_path.exists():
76
+ env_content = env_path.read_text()
77
+
78
+ # Update or add SECRET_KEY
79
+ secret_key_pattern = re.compile(r"^SECRET_KEY=.*$", re.MULTILINE)
80
+ new_line = f"SECRET_KEY={key}"
81
+
82
+ if secret_key_pattern.search(env_content):
83
+ # Replace existing SECRET_KEY
84
+ env_content = secret_key_pattern.sub(new_line, env_content)
85
+ else:
86
+ # Add SECRET_KEY at the end
87
+ if env_content and not env_content.endswith("\n"):
88
+ env_content += "\n"
89
+ env_content += new_line + "\n"
90
+
91
+ env_path.write_text(env_content)
92
+ click.echo(f"SECRET_KEY written to {env_path}")
93
+ else:
94
+ click.echo(key)
95
+
96
+
97
+ @cli.command(
98
+ context_settings=dict(
99
+ ignore_unknown_options=True,
100
+ allow_extra_args=True,
101
+ )
102
+ )
103
+ @click.pass_context
104
+ def db(ctx):
105
+ """Run database migrations via Alembic.
106
+
107
+ \b
108
+ Examples:
109
+ skrift db upgrade head # Apply all migrations
110
+ skrift db downgrade -1 # Rollback one migration
111
+ skrift db current # Show current revision
112
+ skrift db history # Show migration history
113
+ skrift db revision -m "description" --autogenerate # Create new migration
114
+ """
115
+ from alembic.config import main as alembic_main
116
+
117
+ # Always run from the project root (where app.yaml and .env are)
118
+ # This ensures database paths like ./app.db resolve correctly
119
+ project_root = Path.cwd()
120
+ if not (project_root / "app.yaml").exists():
121
+ # If not in project root, try parent directory
122
+ project_root = Path(__file__).parent.parent
123
+ os.chdir(project_root)
124
+
125
+ # Find alembic.ini - check project root first, then skrift package directory
126
+ alembic_ini = project_root / "alembic.ini"
127
+ if not alembic_ini.exists():
128
+ skrift_dir = Path(__file__).parent
129
+ alembic_ini = skrift_dir / "alembic.ini"
130
+
131
+ if not alembic_ini.exists():
132
+ click.echo("Error: Could not find alembic.ini", err=True)
133
+ click.echo("Make sure you're running from the project root directory.", err=True)
134
+ sys.exit(1)
135
+
136
+ # Build argv for alembic: ['-c', '/path/to/alembic.ini', ...extra_args]
137
+ alembic_argv = ["-c", str(alembic_ini)] + ctx.args
138
+
139
+ sys.exit(alembic_main(alembic_argv))
140
+
141
+
142
+ if __name__ == "__main__":
143
+ cli()
@@ -9,13 +9,38 @@ from pydantic import BaseModel
9
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
10
10
 
11
11
  # Load .env file early so env vars are available for YAML interpolation
12
- # Use explicit path to handle subprocess spawning (uvicorn workers)
13
- _env_file = Path(__file__).parent.parent / ".env"
12
+ # Load from current working directory (where app.yaml lives)
13
+ _env_file = Path.cwd() / ".env"
14
14
  load_dotenv(_env_file)
15
15
 
16
16
  # Pattern to match $VAR_NAME environment variable references
17
17
  ENV_VAR_PATTERN = re.compile(r"\$([A-Z_][A-Z0-9_]*)")
18
18
 
19
+ # Environment configuration
20
+ SKRIFT_ENV = "SKRIFT_ENV"
21
+ DEFAULT_ENVIRONMENT = "production"
22
+
23
+
24
+ def get_environment() -> str:
25
+ """Get the current environment name, normalized to lowercase.
26
+
27
+ Reads from SKRIFT_ENV environment variable. Defaults to "production".
28
+ """
29
+ env = os.environ.get(SKRIFT_ENV, DEFAULT_ENVIRONMENT)
30
+ return env.lower().strip()
31
+
32
+
33
+ def get_config_path() -> Path:
34
+ """Get the path to the environment-specific config file.
35
+
36
+ Production -> app.yaml
37
+ Other envs -> app.{env}.yaml (e.g., app.dev.yaml)
38
+ """
39
+ env = get_environment()
40
+ if env == "production":
41
+ return Path.cwd() / "app.yaml"
42
+ return Path.cwd() / f"app.{env}.yaml"
43
+
19
44
 
20
45
  def interpolate_env_vars(value, strict: bool = True):
21
46
  """Recursively replace $VAR_NAME with os.environ values.
@@ -54,10 +79,10 @@ def load_app_config(interpolate: bool = True, strict: bool = True) -> dict:
54
79
  Returns:
55
80
  Parsed configuration dictionary
56
81
  """
57
- config_path = Path.cwd() / "app.yaml"
82
+ config_path = get_config_path()
58
83
 
59
84
  if not config_path.exists():
60
- raise FileNotFoundError(f"app.yaml not found at {config_path}")
85
+ raise FileNotFoundError(f"{config_path.name} not found at {config_path}")
61
86
 
62
87
  with open(config_path, "r") as f:
63
88
  config = yaml.safe_load(f)
@@ -69,7 +94,7 @@ def load_app_config(interpolate: bool = True, strict: bool = True) -> dict:
69
94
 
70
95
  def load_raw_app_config() -> dict | None:
71
96
  """Load app.yaml without any processing. Returns None if file doesn't exist."""
72
- config_path = Path.cwd() / "app.yaml"
97
+ config_path = get_config_path()
73
98
 
74
99
  if not config_path.exists():
75
100
  return None
@@ -98,11 +123,40 @@ class OAuthProviderConfig(BaseModel):
98
123
  tenant_id: str | None = None
99
124
 
100
125
 
126
+ class DummyProviderConfig(BaseModel):
127
+ """Dummy provider configuration (no credentials required)."""
128
+
129
+ pass
130
+
131
+
132
+ # Union type for provider configs - dummy has no required fields
133
+ ProviderConfig = OAuthProviderConfig | DummyProviderConfig
134
+
135
+
101
136
  class AuthConfig(BaseModel):
102
137
  """Authentication configuration."""
103
138
 
104
139
  redirect_base_url: str = "http://localhost:8000"
105
- providers: dict[str, OAuthProviderConfig] = {}
140
+ providers: dict[str, ProviderConfig] = {}
141
+
142
+ @classmethod
143
+ def _parse_provider(cls, name: str, config: dict) -> ProviderConfig:
144
+ """Parse a provider config, using the appropriate model based on provider name."""
145
+ if name == "dummy":
146
+ return DummyProviderConfig(**config)
147
+ return OAuthProviderConfig(**config)
148
+
149
+ def __init__(self, **data):
150
+ # Convert raw provider dicts to appropriate config objects
151
+ if "providers" in data and isinstance(data["providers"], dict):
152
+ parsed_providers = {}
153
+ for name, config in data["providers"].items():
154
+ if isinstance(config, dict):
155
+ parsed_providers[name] = self._parse_provider(name, config)
156
+ else:
157
+ parsed_providers[name] = config
158
+ data["providers"] = parsed_providers
159
+ super().__init__(**data)
106
160
 
107
161
  def get_redirect_uri(self, provider: str) -> str:
108
162
  """Get the OAuth callback URL for a provider."""
@@ -137,7 +191,7 @@ def is_config_valid() -> tuple[bool, str | None]:
137
191
  try:
138
192
  config = load_raw_app_config()
139
193
  if config is None:
140
- return False, "app.yaml not found"
194
+ return False, f"{get_config_path().name} not found"
141
195
 
142
196
  # Check database URL
143
197
  db_config = config.get("db", {})