diff --git a/docs/adr/041-database-install-command.md b/docs/adr/041-database-install-command.md
new file mode 100644
index 00000000..d75731cc
--- /dev/null
+++ b/docs/adr/041-database-install-command.md
@@ -0,0 +1,29 @@
+# 041. database:install command
+
+Date: 2026-10-22
+
+## Problem
+
+`fastapi-startkit init --database ...` sets up the database only for new projects. An existing application without a database has to copy a config file and edit `bootstrap/application.py` by hand: import `DatabaseProvider` and its config, and add `(DatabaseProvider, DatabaseConfig)` to `providers=[...]`. `provider:publish` cannot help, because it only knows about the resources of providers that are already registered and booted.
+
+## Alternatives
+
+1. Register the command from `DatabaseProvider`. It would only exist after the database is installed, which is too late.
+2. Generate a provider list in a separate file such as `config/providers.py`. Apps do not use one. Every app built with the installer and every example lists providers inline in the `Application(...)` call in `bootstrap/application.py`.
+3. Add a core command, registered by `AppProvider` next to `provider:publish`, that writes the config file and edits `bootstrap/application.py` in place.
+
+## Decision
+
+Option 3. `uv run artisan database:install [--driver sqlite|postgres|mysql] [--force]`:
+
+- Renders `config/database.py` from the installer's `database/config.py.stub`, so it matches what `fastapi-startkit init` generates. `config/` is created if missing. If the file already exists it is skipped with a message, unless `--force` is passed.
+- Parses `bootstrap/application.py` with `ast` and finds the `Application(...)` call. If its `providers` list already names `DatabaseProvider`, nothing is changed. Otherwise `(DatabaseProvider, DatabaseConfig)` is appended to the list, following its layout: a new line for multi-line lists, inline for single-line lists. If there is no `providers=` keyword, one is added. Missing `from fastapi_startkit.masoniteorm import DatabaseProvider` and `from config.database import DatabaseConfig` imports are inserted after the last top-level import.
+- If `bootstrap/application.py` or the `Application(...)` call is missing, it prints the lines to add by hand and returns 1. The config file is still written.
+
+The command lives in `fastapi_startkit/console/` rather than `masoniteorm/commands/`, because importing `masoniteorm.commands` loads SQLAlchemy, which is an optional extra. Text is inserted at AST-reported positions rather than regenerated with `ast.unparse`, so the user's formatting and comments are kept.
+
+## Validation
+
+- Tests in `tests/console/test_database_install_command.py` run the command against a temporary app directory. They cover a fresh install, a second run that changes nothing, single-line and missing `providers` lists, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, and loading the edited bootstrap file.
+- The full suite passes with coverage of at least 80%.
+- Manual run, twice, against an app generated by `fastapi-startkit init --database none`.
diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md
index 7b657eb1..ef68f0c2 100644
--- a/docs/adr/AGENTS.md
+++ b/docs/adr/AGENTS.md
@@ -36,3 +36,4 @@ Read this index first, then open the records relevant to the change. Before impl
| [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. |
| [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. |
| [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. |
+| [041](041-database-install-command.md) | 2026-10-22 | database:install command | Core `database:install` command (registered by `AppProvider`, so it exists before the database is set up) renders `config/database.py` from the installer's database stub (`--driver`, defaults to sqlite) and skips an existing file unless `--force` is passed. It uses `ast` to find `Application(...)` in `bootstrap/application.py`, then inserts `(DatabaseProvider, DatabaseConfig)` and any missing imports at the reported positions, so formatting is kept. It does nothing when `DatabaseProvider` is already listed. If the bootstrap file is missing, it prints manual steps and exits 1. It lives in `console/` so the optional SQLAlchemy extra is not imported. |
diff --git a/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py b/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py
new file mode 100644
index 00000000..ad71492e
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py
@@ -0,0 +1,155 @@
+import ast
+from collections.abc import Sequence
+from importlib.resources import files
+from pathlib import Path
+from string import Template
+
+from cleo.helpers import option
+
+from fastapi_startkit.console.command import Command
+from fastapi_startkit.installer.plan import DRIVER_CLASSES
+
+CONFIG_PATH = "config/database.py"
+BOOTSTRAP_PATH = "bootstrap/application.py"
+PROVIDER = "(DatabaseProvider, DatabaseConfig)"
+IMPORTS = {
+ "DatabaseProvider": "from fastapi_startkit.masoniteorm import DatabaseProvider",
+ "DatabaseConfig": "from config.database import DatabaseConfig",
+}
+
+Insert = tuple[int, str]
+
+
+class DatabaseInstallCommand(Command):
+ name = "database:install"
+ description = "Publish config/database.py and register DatabaseProvider in bootstrap/application.py."
+
+ options = [
+ option(
+ "driver",
+ "d",
+ description=f"Database driver: {', '.join(DRIVER_CLASSES)}.",
+ flag=False,
+ default="sqlite",
+ ),
+ option("force", "f", description=f"Overwrite an existing {CONFIG_PATH}."),
+ ]
+
+ def handle(self) -> int:
+ driver = self.option("driver")
+ if driver not in DRIVER_CLASSES:
+ self.line_error(f"Unsupported driver '{driver}'. Use one of: {', '.join(DRIVER_CLASSES)}.")
+ return 1
+
+ base_path = Path(self.container.base_path)
+ self.write_config(base_path / CONFIG_PATH, driver)
+ return self.register_provider(base_path / BOOTSTRAP_PATH)
+
+ def write_config(self, path: Path, driver: str) -> None:
+ if path.exists() and not self.option("force"):
+ self.line(f"{CONFIG_PATH} already exists, skipped. Use --force to overwrite it.")
+ return
+
+ stub = files("fastapi_startkit.installer").joinpath("stubs/database/config.py.stub").read_text(encoding="utf-8")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(Template(stub).substitute(driver=driver, driver_class=DRIVER_CLASSES[driver]), encoding="utf-8")
+ self.line(f"Created {CONFIG_PATH}")
+
+ def register_provider(self, path: Path) -> int:
+ source = path.read_text(encoding="utf-8") if path.exists() else None
+ updated = register_database_provider(source) if source is not None else None
+
+ if updated is None:
+ self.line_error(f"Could not find Application(providers=[...]) in {BOOTSTRAP_PATH}.")
+ self.line_error("Register the provider manually:")
+ for statement in IMPORTS.values():
+ self.line_error(f" {statement}")
+ self.line_error(f" providers=[..., {PROVIDER}]")
+ return 1
+
+ if updated == source:
+ self.line(f"DatabaseProvider is already registered in {BOOTSTRAP_PATH}.")
+ return 0
+
+ path.write_text(updated, encoding="utf-8")
+ self.line(f"Registered DatabaseProvider in {BOOTSTRAP_PATH}")
+ return 0
+
+
+def register_database_provider(source: str) -> str | None:
+ tree = ast.parse(source)
+ call = find_application_call(tree)
+ if call is None:
+ return None
+
+ lines = source.splitlines(keepends=True)
+ keyword = next((keyword for keyword in call.keywords if keyword.arg == "providers"), None)
+
+ if keyword is None:
+ inserts = append_item(source, lines, call, [*call.args, *call.keywords], f"providers=[{PROVIDER}]")
+ elif not isinstance(keyword.value, ast.List):
+ return None
+ elif any(isinstance(node, ast.Name) and node.id == "DatabaseProvider" for node in ast.walk(keyword.value)):
+ return source
+ else:
+ inserts = append_item(source, lines, keyword.value, keyword.value.elts, PROVIDER)
+
+ inserts.extend(import_inserts(tree, lines))
+ for position, text in sorted(inserts, key=lambda insert: insert[0], reverse=True):
+ source = source[:position] + text + source[position:]
+ return source
+
+
+def find_application_call(tree: ast.Module) -> ast.Call | None:
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ if (isinstance(func, ast.Name) and func.id == "Application") or (
+ isinstance(func, ast.Attribute) and func.attr == "Application"
+ ):
+ return node
+ return None
+
+
+def append_item(
+ source: str, lines: list[str], node: ast.expr, items: Sequence[ast.expr | ast.keyword], text: str
+) -> list[Insert]:
+ assert node.end_lineno is not None and node.end_col_offset is not None
+ close = offset(lines, node.end_lineno, node.end_col_offset - 1)
+ if not items:
+ return [(close, text)]
+
+ last = max(items, key=lambda item: (item.end_lineno or 0, item.end_col_offset or 0))
+ assert last.end_lineno is not None and last.end_col_offset is not None
+ end = offset(lines, last.end_lineno, last.end_col_offset)
+ has_comma = source[end:close].lstrip().startswith(",")
+
+ if last.end_lineno == node.end_lineno:
+ return [(close, (" " if has_comma else ", ") + text)]
+
+ line = lines[last.end_lineno - 1]
+ line_end = offset(lines, last.end_lineno, 0) + len(line.rstrip("\r\n"))
+ indent = line[: len(line) - len(line.lstrip())]
+ inserts = [(line_end, f"\n{indent}{text},")]
+ if not has_comma:
+ inserts.append((end, ","))
+ return inserts
+
+
+def import_inserts(tree: ast.Module, lines: list[str]) -> list[Insert]:
+ imports = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))]
+ imported = {alias.asname or alias.name for node in imports for alias in node.names}
+ missing = [statement for name, statement in IMPORTS.items() if name not in imported]
+ if not missing:
+ return []
+ if not imports:
+ return [(0, "\n".join(missing) + "\n\n")]
+
+ last = imports[-1].end_lineno
+ assert last is not None
+ return [(offset(lines, last, 0) + len(lines[last - 1].rstrip("\r\n")), "\n" + "\n".join(missing))]
+
+
+def offset(lines: list[str], lineno: int, col: int) -> int:
+ return sum(len(line) for line in lines[: lineno - 1]) + len(lines[lineno - 1].encode()[:col].decode())
diff --git a/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py b/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py
index 2f43a87f..69e0ac98 100644
--- a/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py
+++ b/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py
@@ -1,3 +1,4 @@
+from fastapi_startkit.console.database_install_command import DatabaseInstallCommand
from fastapi_startkit.console.publish_command import PublishCommand
from fastapi_startkit.support import Provider
@@ -7,4 +8,4 @@ def register(self) -> None:
pass
def boot(self) -> None:
- self.commands([PublishCommand])
+ self.commands([PublishCommand, DatabaseInstallCommand])
diff --git a/fastapi_startkit/src/fastapi_startkit/installer/plan.py b/fastapi_startkit/src/fastapi_startkit/installer/plan.py
index 00dcf3ec..711ecd3c 100644
--- a/fastapi_startkit/src/fastapi_startkit/installer/plan.py
+++ b/fastapi_startkit/src/fastapi_startkit/installer/plan.py
@@ -6,6 +6,8 @@
from pathlib import Path
from string import Template
+DRIVER_CLASSES = {"sqlite": "SQLiteConfig", "postgres": "PostgresConfig", "mysql": "MySQLConfig"}
+
@dataclass(frozen=True)
class InstallOptions:
@@ -117,7 +119,7 @@ def define_web() -> Section:
def define_database(driver: str, project_name: str) -> Section:
if driver == "none":
return Section()
- driver_class = {"sqlite": "SQLiteConfig", "postgres": "PostgresConfig", "mysql": "MySQLConfig"}[driver]
+ driver_class = DRIVER_CLASSES[driver]
environment = {"DB_CONNECTION": driver}
if driver == "sqlite":
environment["DB_DATABASE"] = "databases/database.sqlite"
diff --git a/fastapi_startkit/tests/console/test_database_install_command.py b/fastapi_startkit/tests/console/test_database_install_command.py
new file mode 100644
index 00000000..72f80caa
--- /dev/null
+++ b/fastapi_startkit/tests/console/test_database_install_command.py
@@ -0,0 +1,189 @@
+import ast
+from pathlib import Path
+from types import SimpleNamespace
+
+from cleo.testers.command_tester import CommandTester
+
+from fastapi_startkit.console.database_install_command import DatabaseInstallCommand, register_database_provider
+from fastapi_startkit.foundation.app_provider import AppProvider
+
+MULTILINE_BOOTSTRAP = """from pathlib import Path
+
+from fastapi_startkit import Application
+from fastapi_startkit.logging import LogProvider
+from config.app import AppConfig
+from config.logging import LoggingConfig
+
+app = Application(
+ base_path=Path(__file__).resolve().parent.parent,
+ config=AppConfig,
+ providers=[
+ (LogProvider, LoggingConfig) # logging
+ ],
+)
+"""
+
+
+def run(base_path: Path, args: str = "") -> CommandTester:
+ command = DatabaseInstallCommand()
+ command.set_container(SimpleNamespace(base_path=base_path)) # type: ignore[arg-type]
+ tester = CommandTester(command)
+ tester.execute(args)
+ return tester
+
+
+def make_app(tmp_path: Path, bootstrap: str = MULTILINE_BOOTSTRAP) -> Path:
+ (tmp_path / "bootstrap").mkdir()
+ (tmp_path / "bootstrap/application.py").write_text(bootstrap)
+ return tmp_path
+
+
+def providers_of(source: str) -> list[str]:
+ for node in ast.walk(ast.parse(source)):
+ if isinstance(node, ast.keyword) and node.arg == "providers":
+ assert isinstance(node.value, ast.List)
+ return [ast.unparse(item) for item in node.value.elts]
+ raise AssertionError("providers keyword not found")
+
+
+def test_installs_config_and_registers_provider(tmp_path):
+ app = make_app(tmp_path)
+
+ tester = run(app)
+
+ assert tester.status_code == 0
+ config = (app / "config/database.py").read_text()
+ assert 'env("DB_CONNECTION", "sqlite")' in config
+ assert "SQLiteConfig()" in config
+ bootstrap = (app / "bootstrap/application.py").read_text()
+ assert providers_of(bootstrap) == ["(LogProvider, LoggingConfig)", "(DatabaseProvider, DatabaseConfig)"]
+ assert (
+ " (LogProvider, LoggingConfig), # logging\n (DatabaseProvider, DatabaseConfig),\n" in bootstrap
+ )
+ assert (
+ "from config.logging import LoggingConfig\n"
+ "from fastapi_startkit.masoniteorm import DatabaseProvider\n"
+ "from config.database import DatabaseConfig\n"
+ ) in bootstrap
+ output = tester.io.fetch_output()
+ assert "Created config/database.py" in output
+ assert "Registered DatabaseProvider in bootstrap/application.py" in output
+
+
+def test_second_run_changes_nothing(tmp_path):
+ app = make_app(tmp_path)
+ run(app)
+ config = (app / "config/database.py").read_text()
+ bootstrap = (app / "bootstrap/application.py").read_text()
+
+ tester = run(app, "--driver postgres")
+
+ assert tester.status_code == 0
+ assert (app / "config/database.py").read_text() == config
+ assert (app / "bootstrap/application.py").read_text() == bootstrap
+ output = tester.io.fetch_output()
+ assert "config/database.py already exists, skipped" in output
+ assert "DatabaseProvider is already registered" in output
+
+
+def test_force_overwrites_existing_config(tmp_path):
+ app = make_app(tmp_path)
+ (app / "config").mkdir()
+ (app / "config/database.py").write_text("OLD = True\n")
+
+ tester = run(app, "--driver mysql --force")
+
+ assert tester.status_code == 0
+ assert "MySQLConfig()" in (app / "config/database.py").read_text()
+
+
+def test_rejects_unknown_driver(tmp_path):
+ app = make_app(tmp_path)
+
+ tester = run(app, "--driver oracle")
+
+ assert tester.status_code == 1
+ assert "Unsupported driver 'oracle'" in tester.io.fetch_error()
+ assert not (app / "config").exists()
+
+
+def test_missing_bootstrap_prints_manual_steps(tmp_path):
+ tester = run(tmp_path)
+
+ assert tester.status_code == 1
+ assert (tmp_path / "config/database.py").exists()
+ error = tester.io.fetch_error()
+ assert "Could not find Application(providers=[...]) in bootstrap/application.py" in error
+ assert "from fastapi_startkit.masoniteorm import DatabaseProvider" in error
+
+
+def test_single_line_providers_list():
+ source = "from fastapi_startkit import Application\n\napp = Application(providers=[LogProvider, Other])\n"
+
+ updated = register_database_provider(source)
+
+ assert updated is not None
+ assert "Application(providers=[LogProvider, Other, (DatabaseProvider, DatabaseConfig)])" in updated
+
+
+def test_single_line_providers_list_with_trailing_comma_and_empty_list():
+ assert "providers=[A, (DatabaseProvider, DatabaseConfig)]" in (
+ register_database_provider("app = Application(providers=[A,])\n") or ""
+ )
+ assert "providers=[(DatabaseProvider, DatabaseConfig)]" in (
+ register_database_provider("app = Application(providers=[])\n") or ""
+ )
+
+
+def test_adds_providers_keyword_when_missing():
+ source = "from fastapi_startkit import Application\n\napp = Application(\n base_path=None\n)\n"
+
+ updated = register_database_provider(source)
+
+ assert updated is not None
+ assert " base_path=None,\n providers=[(DatabaseProvider, DatabaseConfig)],\n)" in updated
+ assert providers_of(updated) == ["(DatabaseProvider, DatabaseConfig)"]
+
+
+def test_adds_imports_to_file_without_imports():
+ updated = register_database_provider("app = fastapi_startkit.Application()\n")
+
+ assert updated is not None
+ assert updated.startswith("from fastapi_startkit.masoniteorm import DatabaseProvider\n")
+ assert "Application(providers=[(DatabaseProvider, DatabaseConfig)])" in updated
+
+
+def test_keeps_existing_imports():
+ source = (
+ "from fastapi_startkit.masoniteorm.providers import DatabaseProvider\n"
+ "from config.database import DatabaseConfig\n"
+ "app = Application(providers=[])\n"
+ )
+
+ updated = register_database_provider(source)
+
+ assert updated is not None
+ assert updated.count("import DatabaseProvider") == 1
+ assert updated.count("import DatabaseConfig") == 1
+
+
+def test_unsupported_bootstrap_shapes_return_none():
+ assert register_database_provider("app = object()\n") is None
+ assert register_database_provider("app = Application(providers=PROVIDERS)\n") is None
+
+
+def test_edited_bootstrap_compiles(tmp_path):
+ app = make_app(tmp_path)
+
+ run(app)
+
+ compile((app / "bootstrap/application.py").read_text(), "application.py", "exec")
+
+
+def test_app_provider_registers_command():
+ application = SimpleNamespace(commands=[])
+ application.add_commands = application.commands.extend
+
+ AppProvider(application).boot() # type: ignore[arg-type]
+
+ assert DatabaseInstallCommand in application.commands