litestar-vite 0.1.1__py3-none-any.whl → 0.15.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 (169) hide show
  1. litestar_vite/__init__.py +54 -4
  2. litestar_vite/__metadata__.py +12 -7
  3. litestar_vite/cli.py +1048 -10
  4. litestar_vite/codegen/__init__.py +48 -0
  5. litestar_vite/codegen/_export.py +229 -0
  6. litestar_vite/codegen/_inertia.py +619 -0
  7. litestar_vite/codegen/_openapi.py +280 -0
  8. litestar_vite/codegen/_routes.py +720 -0
  9. litestar_vite/codegen/_ts.py +235 -0
  10. litestar_vite/codegen/_utils.py +141 -0
  11. litestar_vite/commands.py +73 -0
  12. litestar_vite/config/__init__.py +997 -0
  13. litestar_vite/config/_constants.py +97 -0
  14. litestar_vite/config/_deploy.py +70 -0
  15. litestar_vite/config/_inertia.py +241 -0
  16. litestar_vite/config/_paths.py +63 -0
  17. litestar_vite/config/_runtime.py +235 -0
  18. litestar_vite/config/_spa.py +93 -0
  19. litestar_vite/config/_types.py +94 -0
  20. litestar_vite/deploy.py +366 -0
  21. litestar_vite/doctor.py +1181 -0
  22. litestar_vite/exceptions.py +78 -0
  23. litestar_vite/executor.py +360 -0
  24. litestar_vite/handler/__init__.py +9 -0
  25. litestar_vite/handler/_app.py +612 -0
  26. litestar_vite/handler/_routing.py +130 -0
  27. litestar_vite/html_transform.py +569 -0
  28. litestar_vite/inertia/__init__.py +77 -0
  29. litestar_vite/inertia/_utils.py +119 -0
  30. litestar_vite/inertia/exception_handler.py +178 -0
  31. litestar_vite/inertia/helpers.py +1571 -0
  32. litestar_vite/inertia/middleware.py +54 -0
  33. litestar_vite/inertia/plugin.py +199 -0
  34. litestar_vite/inertia/precognition.py +274 -0
  35. litestar_vite/inertia/request.py +334 -0
  36. litestar_vite/inertia/response.py +802 -0
  37. litestar_vite/inertia/types.py +335 -0
  38. litestar_vite/loader.py +464 -123
  39. litestar_vite/plugin/__init__.py +687 -0
  40. litestar_vite/plugin/_process.py +185 -0
  41. litestar_vite/plugin/_proxy.py +689 -0
  42. litestar_vite/plugin/_proxy_headers.py +244 -0
  43. litestar_vite/plugin/_static.py +37 -0
  44. litestar_vite/plugin/_utils.py +489 -0
  45. litestar_vite/py.typed +0 -0
  46. litestar_vite/scaffolding/__init__.py +20 -0
  47. litestar_vite/scaffolding/generator.py +270 -0
  48. litestar_vite/scaffolding/templates.py +437 -0
  49. litestar_vite/templates/__init__.py +0 -0
  50. litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
  51. litestar_vite/templates/angular/index.html.j2 +12 -0
  52. litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
  53. litestar_vite/templates/angular/package.json.j2 +36 -0
  54. litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
  55. litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
  56. litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
  57. litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
  58. litestar_vite/templates/angular/src/main.ts.j2 +9 -0
  59. litestar_vite/templates/angular/src/styles.css.j2 +9 -0
  60. litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
  61. litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
  62. litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
  63. litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
  64. litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
  65. litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
  66. litestar_vite/templates/angular-cli/package.json.j2 +28 -0
  67. litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
  68. litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
  69. litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
  70. litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
  71. litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
  72. litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
  73. litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
  74. litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
  75. litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
  76. litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
  77. litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
  78. litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
  79. litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
  80. litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
  81. litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
  82. litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
  83. litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
  84. litestar_vite/templates/base/.gitignore.j2 +42 -0
  85. litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
  86. litestar_vite/templates/base/package.json.j2 +39 -0
  87. litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
  88. litestar_vite/templates/base/tsconfig.json.j2 +37 -0
  89. litestar_vite/templates/htmx/src/main.js.j2 +8 -0
  90. litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
  91. litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
  92. litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
  93. litestar_vite/templates/nuxt/app.vue.j2 +29 -0
  94. litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
  95. litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
  96. litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
  97. litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
  98. litestar_vite/templates/react/index.html.j2 +13 -0
  99. litestar_vite/templates/react/src/App.css.j2 +56 -0
  100. litestar_vite/templates/react/src/App.tsx.j2 +19 -0
  101. litestar_vite/templates/react/src/main.tsx.j2 +10 -0
  102. litestar_vite/templates/react/vite.config.ts.j2 +39 -0
  103. litestar_vite/templates/react-inertia/index.html.j2 +14 -0
  104. litestar_vite/templates/react-inertia/package.json.j2 +47 -0
  105. litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
  106. litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
  107. litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
  108. litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
  109. litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
  110. litestar_vite/templates/react-router/index.html.j2 +12 -0
  111. litestar_vite/templates/react-router/src/App.css.j2 +17 -0
  112. litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
  113. litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
  114. litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
  115. litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
  116. litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
  117. litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
  118. litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
  119. litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
  120. litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
  121. litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
  122. litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
  123. litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
  124. litestar_vite/templates/svelte/index.html.j2 +13 -0
  125. litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
  126. litestar_vite/templates/svelte/src/app.css.j2 +45 -0
  127. litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
  128. litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
  129. litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
  130. litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
  131. litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
  132. litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
  133. litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
  134. litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
  135. litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
  136. litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
  137. litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
  138. litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
  139. litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
  140. litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
  141. litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
  142. litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
  143. litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
  144. litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
  145. litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
  146. litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
  147. litestar_vite/templates/vue/env.d.ts.j2 +7 -0
  148. litestar_vite/templates/vue/index.html.j2 +13 -0
  149. litestar_vite/templates/vue/src/App.vue.j2 +28 -0
  150. litestar_vite/templates/vue/src/main.ts.j2 +5 -0
  151. litestar_vite/templates/vue/src/style.css.j2 +45 -0
  152. litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
  153. litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
  154. litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
  155. litestar_vite/templates/vue-inertia/package.json.j2 +50 -0
  156. litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
  157. litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
  158. litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
  159. litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
  160. litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
  161. litestar_vite-0.15.0.dist-info/METADATA +230 -0
  162. litestar_vite-0.15.0.dist-info/RECORD +164 -0
  163. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/WHEEL +1 -1
  164. litestar_vite/config.py +0 -100
  165. litestar_vite/plugin.py +0 -45
  166. litestar_vite/template_engine.py +0 -103
  167. litestar_vite-0.1.1.dist-info/METADATA +0 -68
  168. litestar_vite-0.1.1.dist-info/RECORD +0 -11
  169. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1181 @@
1
+ """Vite Doctor - Diagnostic Tool.
2
+
3
+ This module provides a best-effort diagnostic utility that checks Litestar ↔ Vite configuration alignment.
4
+ Regex patterns used for vite.config parsing are compiled at import time to avoid repeated compilation overhead.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import socket
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, Literal
13
+
14
+ from litestar.serialization import decode_json, encode_json
15
+ from rich.console import Console, Group
16
+ from rich.panel import Panel
17
+ from rich.prompt import Confirm
18
+ from rich.syntax import Syntax
19
+ from rich.table import Table
20
+
21
+ from litestar_vite.config import ExternalDevServer, TypeGenConfig
22
+
23
+ if TYPE_CHECKING:
24
+ from litestar_vite.config import ViteConfig
25
+
26
+ console = Console()
27
+
28
+
29
+ def _str_list_factory() -> list[str]:
30
+ """Return an empty ``list[str]`` (typed for pyright).
31
+
32
+ Returns:
33
+ An empty list.
34
+ """
35
+ return []
36
+
37
+
38
+ _VITE_CONFIG_PATTERNS: dict[str, re.Pattern[str]] = {
39
+ "asset_url": re.compile(r"""assetUrl\s*:\s*['"]([^'"]+)['"]"""),
40
+ "bundle_dir": re.compile(r"""bundleDir\s*:\s*['"]([^'"]+)['"]"""),
41
+ "resource_dir": re.compile(r"""resourceDir\s*:\s*['"]([^'"]+)['"]"""),
42
+ "static_dir": re.compile(r"""staticDir\s*:\s*['"]([^'"]+)['"]"""),
43
+ "hot_file": re.compile(r"""hotFile\s*:\s*['"]([^'"]+)['"]"""),
44
+ "inertia_mode": re.compile(r"""inertiaMode\s*:\s*(true|false)"""),
45
+ "types_enabled": re.compile(r"""types\s*:\s*{\s*enabled\s*:\s*(true|false)"""),
46
+ "types_output": re.compile(r"""output\s*:\s*['"]([^'"]+)['"]"""),
47
+ "types_openapi": re.compile(r"""openapiPath\s*:\s*['"]([^'"]+)['"]"""),
48
+ "types_routes": re.compile(r"""routesPath\s*:\s*['"]([^'"]+)['"]"""),
49
+ "types_generate_zod": re.compile(r"""generateZod\s*:\s*(true|false)"""),
50
+ "types_generate_sdk": re.compile(r"""generateSdk\s*:\s*(true|false)"""),
51
+ }
52
+
53
+ _LITESTAR_CONFIG_START = re.compile(r"\blitestar\s*\(\s*{", re.MULTILINE)
54
+
55
+
56
+ def _format_ts_literal(value: Any) -> str:
57
+ """Format a Python value as a TypeScript literal for basic primitives.
58
+
59
+ Returns:
60
+ A TypeScript literal string.
61
+ """
62
+ if isinstance(value, bool):
63
+ return "true" if value else "false"
64
+ if isinstance(value, (int, float)):
65
+ return str(value)
66
+ s = str(value).replace("\\", "\\\\").replace("'", "\\'")
67
+ return f"'{s}'"
68
+
69
+
70
+ def _extract_braced_block(source: str, open_brace_index: int) -> tuple[str, int, int] | None:
71
+ """Extract a balanced braced block from ``source`` starting at ``open_brace_index``.
72
+
73
+ Best-effort parser intended for typical vite.config.* formatting. Ignores braces
74
+ inside quoted strings.
75
+
76
+ Returns:
77
+ A tuple of (extracted block, start index, end index), or None if not found.
78
+ """
79
+ if open_brace_index < 0 or open_brace_index >= len(source) or source[open_brace_index] != "{":
80
+ return None
81
+
82
+ depth = 0
83
+ in_string: str | None = None
84
+ escape = False
85
+
86
+ for i in range(open_brace_index, len(source)):
87
+ ch = source[i]
88
+ if in_string is not None:
89
+ if escape:
90
+ escape = False
91
+ continue
92
+ if ch == "\\":
93
+ escape = True
94
+ continue
95
+ if ch == in_string:
96
+ in_string = None
97
+ continue
98
+
99
+ if ch in {"'", '"', "`"}:
100
+ in_string = ch
101
+ continue
102
+
103
+ if ch == "{":
104
+ depth += 1
105
+ continue
106
+ if ch == "}":
107
+ depth -= 1
108
+ if depth == 0:
109
+ return source[open_brace_index : i + 1], open_brace_index, i + 1
110
+
111
+ return None
112
+
113
+
114
+ def _rel_to_root(path: Path | None, root: Path) -> str:
115
+ """Return a stable string representation of ``path`` relative to ``root`` when possible.
116
+
117
+ Args:
118
+ path: Candidate path to render.
119
+ root: Root directory for relative rendering.
120
+
121
+ Returns:
122
+ A relative path string when ``path`` is under ``root``, otherwise a short fallback representation.
123
+ """
124
+ if path is None:
125
+ return ""
126
+ try:
127
+ return str(path.resolve().relative_to(root.resolve()))
128
+ except ValueError:
129
+ parts = path.resolve().parts
130
+ return "/".join(parts[-3:])
131
+
132
+
133
+ @dataclass
134
+ class DoctorIssue:
135
+ """Represents a detected configuration issue."""
136
+
137
+ check: str
138
+ severity: Literal["error", "warning"]
139
+ message: str
140
+ fix_hint: str
141
+ auto_fixable: bool = False
142
+ context: dict[str, Any] | None = None
143
+
144
+
145
+ @dataclass
146
+ class ParsedViteConfig:
147
+ """Parsed values from vite.config.* file."""
148
+
149
+ path: Path
150
+ content: str
151
+ litestar_config_block: str | None = None
152
+ has_litestar_config: bool = False
153
+ asset_url: str | None = None
154
+ bundle_dir: str | None = None
155
+ resource_dir: str | None = None
156
+ static_dir: str | None = None
157
+ hot_file: str | None = None
158
+ inertia_mode: bool | None = None
159
+ types_setting: Literal["auto"] | bool | None = None
160
+ inputs: list[str] = field(default_factory=_str_list_factory)
161
+ types_enabled: bool | None = None
162
+ types_output: str | None = None
163
+ types_openapi_path: str | None = None
164
+ types_routes_path: str | None = None
165
+ types_generate_zod: bool | None = None
166
+ types_generate_sdk: bool | None = None
167
+
168
+
169
+ class ViteDoctor:
170
+ """Diagnose and fix Vite configuration issues."""
171
+
172
+ def __init__(self, config: "ViteConfig", verbose: bool = False) -> None:
173
+ self.config = config
174
+ self.verbose = verbose
175
+ self.issues: list[DoctorIssue] = []
176
+ self.vite_config_path: Path | None = None
177
+ self.parsed_config: ParsedViteConfig | None = None
178
+ self.bridge_path: Path | None = None
179
+ self.bridge_config: dict[str, Any] | None = None
180
+
181
+ def run(
182
+ self, fix: bool = False, no_prompt: bool = False, *, show_config: bool = False, runtime_checks: bool = False
183
+ ) -> bool:
184
+ """Run diagnostics and optionally fix issues.
185
+
186
+ When ``fix=True``, auto-fixable issues may be applied and the checks will be run again to produce an accurate
187
+ final status.
188
+
189
+ Returns:
190
+ True if healthy (after fixes), False if issues remain.
191
+ """
192
+ self.issues = []
193
+ self.vite_config_path = None
194
+ self.parsed_config = None
195
+ self.bridge_path = None
196
+ self.bridge_config = None
197
+
198
+ console.rule("[yellow]Vite Doctor Diagnostics[/]", align="left")
199
+
200
+ self._locate_vite_config()
201
+ if not self.vite_config_path or not self.parsed_config:
202
+ console.print("[red]✗ Could not locate or parse vite.config.* file[/]")
203
+ return False
204
+
205
+ self._locate_bridge_file()
206
+ self._maybe_load_bridge_config()
207
+
208
+ self._print_config_snapshot(show_bridge=show_config)
209
+
210
+ self._check_litestar_plugin_config()
211
+ self._check_bridge_file()
212
+ self._check_paths_exist()
213
+ self._check_asset_url()
214
+ self._check_hot_file()
215
+ self._check_bundle_dir()
216
+ self._check_resource_dir()
217
+ self._check_static_dir()
218
+ self._check_inertia_mode()
219
+ self._check_types_setting_alignment()
220
+ self._check_input_paths()
221
+ self._check_typegen_paths()
222
+ self._check_typegen_flags()
223
+ self._check_plugin_spread()
224
+ self._check_dist_files()
225
+ self._check_hotfile_presence()
226
+ self._check_manifest_presence()
227
+ self._check_typegen_artifacts()
228
+ self._check_env_alignment()
229
+
230
+ self._check_node_modules()
231
+ if runtime_checks:
232
+ self._check_hotfile_presence()
233
+ self._check_vite_server_reachable()
234
+
235
+ self._print_report()
236
+
237
+ errors = [i for i in self.issues if i.severity == "error"]
238
+ warnings = [i for i in self.issues if i.severity != "error"]
239
+
240
+ if not self.issues:
241
+ console.print("\n[green]✓ No issues found. Configuration looks healthy.[/]")
242
+ return True
243
+
244
+ if not errors and warnings:
245
+ console.print(f"\n[green]✓ No errors found.[/] [yellow]{len(warnings)} warning(s) detected.[/]")
246
+
247
+ if fix:
248
+ fixed = self._apply_fixes(no_prompt)
249
+ if not fixed:
250
+ return False
251
+ return self.run(fix=False, no_prompt=no_prompt, show_config=show_config)
252
+
253
+ return not errors
254
+
255
+ def _locate_vite_config(self) -> None:
256
+ """Find and parse the vite.config file."""
257
+ root = self.config.root_dir or Path.cwd()
258
+ for ext in [".ts", ".js", ".mts", ".mjs"]:
259
+ path = root / f"vite.config{ext}"
260
+ if path.exists():
261
+ self.vite_config_path = path
262
+ content = path.read_text()
263
+ self.parsed_config = self._parse_vite_config(path, content)
264
+ if self.verbose:
265
+ console.print(f"[dim]Found config at {path}[/]")
266
+ return
267
+
268
+ def _locate_bridge_file(self) -> None:
269
+ """Locate the .litestar.json bridge file."""
270
+ root = self.config.root_dir or Path.cwd()
271
+ self.bridge_path = root / ".litestar.json"
272
+
273
+ def _maybe_load_bridge_config(self) -> None:
274
+ """Load .litestar.json if present."""
275
+ if self.bridge_path is None or not self.bridge_path.exists():
276
+ return
277
+ try:
278
+ decoded = decode_json(self.bridge_path.read_bytes())
279
+ if isinstance(decoded, dict):
280
+ self.bridge_config = decoded
281
+ except Exception as e: # noqa: BLE001 - diagnostic tool
282
+ self.bridge_config = {"__error__": str(e)}
283
+
284
+ def _parse_vite_config(self, path: Path, content: str) -> ParsedViteConfig:
285
+ """Regex-based parsing of vite.config content.
286
+
287
+ Parsing is restricted to the ``litestar({ ... })`` config block when present to reduce false positives.
288
+ Inputs and type settings are parsed best-effort to allow slightly different formatting styles.
289
+
290
+ Returns:
291
+ ParsedViteConfig instance with extracted values.
292
+ """
293
+ parsed = ParsedViteConfig(path=path, content=content, inputs=[])
294
+
295
+ config_source = content
296
+ match = _LITESTAR_CONFIG_START.search(content)
297
+ if match:
298
+ open_brace_index = content.find("{", match.start())
299
+ extracted = _extract_braced_block(content, open_brace_index)
300
+ if extracted is not None:
301
+ block, _, _ = extracted
302
+ parsed.litestar_config_block = block
303
+ parsed.has_litestar_config = True
304
+ config_source = block
305
+
306
+ input_single = re.search(r"""input\s*:\s*['"]([^'"]+)['"]""", config_source)
307
+ if input_single:
308
+ parsed.inputs.append(input_single.group(1))
309
+ input_array = re.search(r"""input\s*:\s*\[([^\]]*)\]""", config_source, flags=re.DOTALL)
310
+ if input_array:
311
+ parsed.inputs.extend(re.findall(r"""['"]([^'"]+)['"]""", input_array.group(1)))
312
+
313
+ if re.search(r"""types\s*:\s*['"]auto['"]""", config_source):
314
+ parsed.types_setting = "auto"
315
+ else:
316
+ types_bool = re.search(r"""types\s*:\s*(true|false)\b""", config_source)
317
+ if types_bool:
318
+ parsed.types_setting = types_bool.group(1) == "true"
319
+
320
+ str_key_map: dict[str, str] = {
321
+ "asset_url": "asset_url",
322
+ "bundle_dir": "bundle_dir",
323
+ "resource_dir": "resource_dir",
324
+ "static_dir": "static_dir",
325
+ "hot_file": "hot_file",
326
+ "types_output": "types_output",
327
+ "types_openapi": "types_openapi_path",
328
+ "types_routes": "types_routes_path",
329
+ }
330
+ bool_key_map: dict[str, str] = {
331
+ "inertia_mode": "inertia_mode",
332
+ "types_enabled": "types_enabled",
333
+ "types_generate_zod": "types_generate_zod",
334
+ "types_generate_sdk": "types_generate_sdk",
335
+ }
336
+
337
+ for key, pattern in _VITE_CONFIG_PATTERNS.items():
338
+ found = pattern.search(config_source)
339
+ if not found:
340
+ continue
341
+ val = found.group(1)
342
+
343
+ attr_name = str_key_map.get(key)
344
+ if attr_name is not None:
345
+ setattr(parsed, attr_name, val)
346
+ continue
347
+
348
+ attr_name = bool_key_map.get(key)
349
+ if attr_name is not None:
350
+ setattr(parsed, attr_name, val == "true")
351
+
352
+ return parsed
353
+
354
+ def _apply_write_bridge_fix(self) -> bool:
355
+ if self.bridge_path is None:
356
+ return False
357
+ expected = self._expected_bridge_payload()
358
+ if self.bridge_path.exists():
359
+ bridge_backup = self.bridge_path.with_suffix(self.bridge_path.suffix + ".bak")
360
+ bridge_backup.write_bytes(self.bridge_path.read_bytes())
361
+ console.print(f"[dim]Created backup at {bridge_backup}[/]")
362
+ self.bridge_path.write_bytes(encode_json(expected))
363
+ console.print("[green]✓ Wrote .litestar.json[/]")
364
+ return True
365
+
366
+ def _apply_vite_key_fix(self, content: str, *, key: str, expected: Any) -> tuple[str, bool]:
367
+ expected_literal = _format_ts_literal(expected)
368
+ expected_str = str(expected)
369
+ expected_bool = "true" if expected is True else "false" if expected is False else None
370
+
371
+ bool_pattern = rf"({key}\s*:\s*)(true|false)\b"
372
+ if expected_bool is not None and re.search(bool_pattern, content):
373
+ content = re.sub(bool_pattern, rf"\\g<1>{expected_bool}", content, count=1)
374
+ return content, True
375
+
376
+ quoted_pattern = rf"({key}\s*:\s*['\"])([^'\"]+)(['\"])"
377
+ if re.search(quoted_pattern, content):
378
+ content = re.sub(quoted_pattern, rf"\\g<1>{expected_str}\\g<3>", content, count=1)
379
+ return content, True
380
+
381
+ insert_match = _LITESTAR_CONFIG_START.search(content)
382
+ if insert_match:
383
+ brace_index = content.find("{", insert_match.start())
384
+ line_start = content.rfind("\n", 0, brace_index) + 1
385
+ indent_match = re.match(r"\s*", content[line_start:brace_index])
386
+ indent = indent_match.group(0) if indent_match else ""
387
+ insertion = f"\n{indent} {key}: {expected_literal},"
388
+ content = content[: brace_index + 1] + insertion + content[brace_index + 1 :]
389
+ return content, True
390
+
391
+ return content, False
392
+
393
+ def _resolve_to_root(self, path: Path) -> Path:
394
+ root = self.config.root_dir or Path.cwd()
395
+ return path if path.is_absolute() else (root / path)
396
+
397
+ def _check_litestar_plugin_config(self) -> None:
398
+ """Ensure the vite.config includes a litestar({ ... }) plugin config."""
399
+ if not self.parsed_config:
400
+ return
401
+ if self.parsed_config.has_litestar_config:
402
+ return
403
+
404
+ self.issues.append(
405
+ DoctorIssue(
406
+ check="Missing litestar() Plugin Config",
407
+ severity="error",
408
+ message=f"{self.parsed_config.path.name} does not appear to contain a litestar({{...}}) call",
409
+ fix_hint=(
410
+ "Add the plugin to vite.config, e.g. `import litestar from 'litestar-vite-plugin'` and "
411
+ "`plugins: [litestar({ input: ['src/main.ts'] })]`"
412
+ ),
413
+ auto_fixable=False,
414
+ )
415
+ )
416
+
417
+ def _expected_bridge_payload(self) -> dict[str, Any]:
418
+ """Build the expected .litestar.json payload from the active Python config.
419
+
420
+ Returns:
421
+ A dictionary representing the expected bridge configuration.
422
+ """
423
+ types = self.config.types if isinstance(self.config.types, TypeGenConfig) else None
424
+ deploy = self.config.deploy_config
425
+ return {
426
+ "assetUrl": self.config.asset_url,
427
+ "deployAssetUrl": deploy.asset_url if deploy is not None and deploy.asset_url else None,
428
+ "bundleDir": str(self.config.bundle_dir),
429
+ "hotFile": self.config.hot_file,
430
+ "resourceDir": str(self.config.resource_dir),
431
+ "staticDir": str(self.config.static_dir),
432
+ "manifest": self.config.manifest_name,
433
+ "mode": self.config.mode,
434
+ "proxyMode": self.config.proxy_mode,
435
+ "port": self.config.port,
436
+ "host": self.config.host,
437
+ "ssrOutDir": str(self.config.ssr_output_dir) if self.config.ssr_output_dir else None,
438
+ "types": (
439
+ {
440
+ "enabled": True,
441
+ "output": str(types.output),
442
+ "openapiPath": str(types.openapi_path),
443
+ "routesPath": str(types.routes_path),
444
+ "generateZod": types.generate_zod,
445
+ "generateSdk": types.generate_sdk,
446
+ }
447
+ if types
448
+ else None
449
+ ),
450
+ }
451
+
452
+ def _check_bridge_file(self) -> None:
453
+ """Validate presence and consistency of .litestar.json when enabled."""
454
+ if not self.config.set_environment:
455
+ return
456
+ if self.bridge_path is None:
457
+ return
458
+
459
+ if not self.bridge_path.exists():
460
+ self.issues.append(
461
+ DoctorIssue(
462
+ check="Missing .litestar.json",
463
+ severity="warning",
464
+ message=f"{self.bridge_path} not found (Vite plugin auto-config may fall back to defaults)",
465
+ fix_hint=(
466
+ "Start your app (`litestar run`) or `litestar assets serve` to generate it, or run "
467
+ "`litestar assets doctor --fix` to write it now"
468
+ ),
469
+ auto_fixable=True,
470
+ context={"action": "write_bridge"},
471
+ )
472
+ )
473
+ return
474
+
475
+ if self.bridge_config is None:
476
+ return
477
+
478
+ if "__error__" in self.bridge_config:
479
+ self.issues.append(
480
+ DoctorIssue(
481
+ check="Invalid .litestar.json",
482
+ severity="error",
483
+ message=str(self.bridge_config.get("__error__")),
484
+ fix_hint=(
485
+ "Run `litestar assets doctor --fix` to rewrite it (a .bak backup is created). If you prefer, "
486
+ "starting your app (`litestar run`) will overwrite it on startup when `runtime.set_environment=True`"
487
+ ),
488
+ auto_fixable=True,
489
+ context={"action": "write_bridge"},
490
+ )
491
+ )
492
+ return
493
+
494
+ expected = self._expected_bridge_payload()
495
+ mismatched: list[str] = []
496
+ root = self.config.root_dir or Path.cwd()
497
+ for key, exp in expected.items():
498
+ actual = self.bridge_config.get(key)
499
+ if key in {"bundleDir", "resourceDir", "staticDir", "ssrOutDir"}:
500
+ exp_path = (root / exp) if isinstance(exp, str) and exp and not Path(exp).is_absolute() else exp
501
+ act_path = (
502
+ (root / actual) if isinstance(actual, str) and actual and not Path(actual).is_absolute() else actual
503
+ )
504
+ if str(exp_path).rstrip("/") != str(act_path).rstrip("/"):
505
+ mismatched.append(key)
506
+ elif actual != exp:
507
+ mismatched.append(key)
508
+
509
+ if mismatched:
510
+ self.issues.append(
511
+ DoctorIssue(
512
+ check="Stale .litestar.json",
513
+ severity="warning",
514
+ message=f"Bridge config differs from Python config for: {', '.join(sorted(mismatched))}",
515
+ fix_hint=(
516
+ "Run `litestar assets doctor --fix` to rewrite it (a .bak backup is created). "
517
+ "Or just restart your app (`litestar run`) to overwrite it on startup when `runtime.set_environment=True`"
518
+ ),
519
+ auto_fixable=True,
520
+ context={"action": "write_bridge"},
521
+ )
522
+ )
523
+
524
+ def _check_paths_exist(self) -> None:
525
+ """Validate core Python paths exist."""
526
+ root = self.config.root_dir or Path.cwd()
527
+ resource_dir = self._resolve_to_root(self.config.resource_dir)
528
+ static_dir = self._resolve_to_root(self.config.static_dir)
529
+
530
+ if not resource_dir.exists():
531
+ self.issues.append(
532
+ DoctorIssue(
533
+ check="Missing resource_dir",
534
+ severity="error",
535
+ message=f"resource_dir does not exist: {_rel_to_root(resource_dir, root)}",
536
+ fix_hint="Create the directory or update ViteConfig.paths.resource_dir to the correct source folder",
537
+ auto_fixable=False,
538
+ )
539
+ )
540
+
541
+ if not static_dir.exists():
542
+ self.issues.append(
543
+ DoctorIssue(
544
+ check="Missing static_dir",
545
+ severity="warning",
546
+ message=f"static_dir does not exist: {_rel_to_root(static_dir, root)}",
547
+ fix_hint=f"Create the directory or update ViteConfig.paths.static_dir (often `${resource_dir}/public`)",
548
+ auto_fixable=False,
549
+ )
550
+ )
551
+
552
+ def _check_asset_url(self) -> None:
553
+ """Check if Python asset_url matches JS assetUrl."""
554
+ if not self.parsed_config:
555
+ return
556
+
557
+ py_url = self.config.asset_url
558
+ js_url = self.parsed_config.asset_url
559
+
560
+ py_norm = py_url.rstrip("/")
561
+ js_norm = (js_url or "").rstrip("/")
562
+
563
+ if js_url and py_norm != js_norm:
564
+ self.issues.append(
565
+ DoctorIssue(
566
+ check="Asset URL Mismatch",
567
+ severity="error",
568
+ message=f"Python asset_url '{py_url}' != JS assetUrl '{js_url}'",
569
+ fix_hint=f"Update vite.config to use assetUrl: '{py_url}'",
570
+ auto_fixable=True,
571
+ context={"key": "assetUrl", "expected": py_url},
572
+ )
573
+ )
574
+
575
+ def _check_hot_file(self) -> None:
576
+ """Check if Python hot_file matches JS hotFile.
577
+
578
+ Litestar's Python config stores ``hot_file`` as a filename (default ``hot``), while the JS plugin commonly
579
+ uses a full path defaulting to ``${bundleDir}/hot``. This check only warns when JS explicitly sets ``hotFile``
580
+ to a value that diverges from the Python expectation.
581
+ """
582
+ if not self.parsed_config:
583
+ return
584
+ expected_hot = f"{self.config.bundle_dir}/{self.config.hot_file}".replace("//", "/")
585
+ js_hot = self.parsed_config.hot_file
586
+
587
+ if js_hot and js_hot != expected_hot:
588
+ self.issues.append(
589
+ DoctorIssue(
590
+ check="Hot File Mismatch",
591
+ severity="warning",
592
+ message=f"JS hotFile '{js_hot}' differs from Python default '{expected_hot}'",
593
+ fix_hint=f"Update vite.config to use hotFile: '{expected_hot}'",
594
+ auto_fixable=True,
595
+ context={"key": "hotFile", "expected": expected_hot},
596
+ )
597
+ )
598
+
599
+ def _check_bundle_dir(self) -> None:
600
+ """Check bundle directory match."""
601
+ if not self.parsed_config:
602
+ return
603
+
604
+ py_dir = str(self.config.bundle_dir)
605
+ js_dir = self.parsed_config.bundle_dir
606
+
607
+ if js_dir and py_dir != js_dir:
608
+ self.issues.append(
609
+ DoctorIssue(
610
+ check="Bundle Directory Mismatch",
611
+ severity="error",
612
+ message=f"Python bundle_dir '{py_dir}' != JS bundleDir '{js_dir}'",
613
+ fix_hint=f"Update vite.config to use bundleDir: '{py_dir}'",
614
+ auto_fixable=True,
615
+ context={"key": "bundleDir", "expected": py_dir},
616
+ )
617
+ )
618
+
619
+ def _check_resource_dir(self) -> None:
620
+ """Check resource directory match when explicitly set in vite.config."""
621
+ if not self.parsed_config:
622
+ return
623
+
624
+ py_dir = str(self.config.resource_dir)
625
+ js_dir = self.parsed_config.resource_dir
626
+
627
+ if js_dir and py_dir != js_dir:
628
+ self.issues.append(
629
+ DoctorIssue(
630
+ check="Resource Directory Mismatch",
631
+ severity="warning",
632
+ message=f"Python resource_dir '{py_dir}' != JS resourceDir '{js_dir}'",
633
+ fix_hint=f"Update vite.config to use resourceDir: '{py_dir}' (or remove it to auto-read from .litestar.json)",
634
+ auto_fixable=True,
635
+ context={"key": "resourceDir", "expected": py_dir},
636
+ )
637
+ )
638
+
639
+ def _check_static_dir(self) -> None:
640
+ """Check static directory match when explicitly set in vite.config."""
641
+ if not self.parsed_config:
642
+ return
643
+
644
+ py_dir = str(self.config.static_dir)
645
+ js_dir = self.parsed_config.static_dir
646
+
647
+ if js_dir and py_dir != js_dir:
648
+ self.issues.append(
649
+ DoctorIssue(
650
+ check="Static Directory Mismatch",
651
+ severity="warning",
652
+ message=f"Python static_dir '{py_dir}' != JS staticDir '{js_dir}'",
653
+ fix_hint=f"Update vite.config to use staticDir: '{py_dir}' (or remove it to auto-read from .litestar.json)",
654
+ auto_fixable=True,
655
+ context={"key": "staticDir", "expected": py_dir},
656
+ )
657
+ )
658
+
659
+ def _check_inertia_mode(self) -> None:
660
+ """Warn when vite.config inertiaMode conflicts with Python mode."""
661
+ if not self.parsed_config or self.parsed_config.inertia_mode is None:
662
+ return
663
+
664
+ py_inertia = self.config.mode == "inertia"
665
+ js_inertia = self.parsed_config.inertia_mode
666
+
667
+ if py_inertia != js_inertia:
668
+ self.issues.append(
669
+ DoctorIssue(
670
+ check="Inertia Mode Mismatch",
671
+ severity="warning",
672
+ message=(
673
+ f"Python mode={self.config.mode!r} implies inertiaMode={py_inertia}, "
674
+ f"but vite.config sets inertiaMode={js_inertia}"
675
+ ),
676
+ fix_hint="Remove inertiaMode from vite.config to auto-detect, or set it to match your Python mode",
677
+ auto_fixable=False,
678
+ )
679
+ )
680
+
681
+ def _check_types_setting_alignment(self) -> None:
682
+ """Warn when vite.config explicitly enables/disables types against Python config."""
683
+ if not self.parsed_config:
684
+ return
685
+
686
+ py_types_enabled = isinstance(self.config.types, TypeGenConfig)
687
+ js_setting = self.parsed_config.types_setting
688
+
689
+ if py_types_enabled and js_setting is False:
690
+ self.issues.append(
691
+ DoctorIssue(
692
+ check="TypeGen Disabled in vite.config",
693
+ severity="warning",
694
+ message="Python types are enabled, but vite.config sets `types: false`",
695
+ fix_hint="Remove `types: false` or set `types: 'auto'` to read from .litestar.json",
696
+ auto_fixable=False,
697
+ )
698
+ )
699
+
700
+ if not py_types_enabled and js_setting is True:
701
+ self.issues.append(
702
+ DoctorIssue(
703
+ check="TypeGen Enabled in vite.config",
704
+ severity="warning",
705
+ message="Python types are disabled, but vite.config sets `types: true`",
706
+ fix_hint="Disable types in vite.config, or enable TypeGenConfig in Python to keep both sides aligned",
707
+ auto_fixable=False,
708
+ )
709
+ )
710
+
711
+ def _check_input_paths(self) -> None:
712
+ """Check that configured entrypoints exist on disk (best-effort)."""
713
+ if not self.parsed_config or not self.parsed_config.inputs:
714
+ return
715
+
716
+ root = self.config.root_dir or Path.cwd()
717
+ missing: list[str] = []
718
+ for input_path in self.parsed_config.inputs:
719
+ p = Path(input_path)
720
+ resolved = p if p.is_absolute() else (root / p)
721
+ if not resolved.exists():
722
+ missing.append(input_path)
723
+
724
+ if missing:
725
+ self.issues.append(
726
+ DoctorIssue(
727
+ check="Missing Vite Inputs",
728
+ severity="error",
729
+ message=f"Entry point(s) not found: {', '.join(missing)}",
730
+ fix_hint="Update the `input` paths in vite.config or ensure the files exist under your frontend source folder",
731
+ auto_fixable=False,
732
+ )
733
+ )
734
+
735
+ def _check_typegen_paths(self) -> None:
736
+ """Check type generation paths when TypeGen is enabled on both sides.
737
+
738
+ Only compares OpenAPI and routes output paths when the corresponding JS settings are explicitly present to
739
+ avoid warning on JS defaults that may differ in representation (absolute vs relative).
740
+ """
741
+ if not self.parsed_config:
742
+ return
743
+
744
+ if not isinstance(self.config.types, TypeGenConfig):
745
+ return
746
+
747
+ if self.parsed_config.types_enabled:
748
+ root = self.config.root_dir or Path.cwd()
749
+
750
+ py_openapi_path = self.config.types.openapi_path
751
+ if py_openapi_path is None:
752
+ py_openapi_path = self.config.types.output / "openapi.json"
753
+ js_openapi_path = (
754
+ (root / self.parsed_config.types_openapi_path) if self.parsed_config.types_openapi_path else None
755
+ )
756
+
757
+ if self.parsed_config.types_openapi_path and _rel_to_root(py_openapi_path, root) != _rel_to_root(
758
+ js_openapi_path, root
759
+ ):
760
+ self.issues.append(
761
+ DoctorIssue(
762
+ check="TypeGen OpenAPI Path Mismatch",
763
+ severity="warning",
764
+ message=f"Python '{py_openapi_path}' != JS '{js_openapi_path}'",
765
+ fix_hint=f"Update vite.config openapiPath to '{py_openapi_path}'",
766
+ auto_fixable=True,
767
+ context={"key": "openapiPath", "expected": str(py_openapi_path)},
768
+ )
769
+ )
770
+
771
+ py_routes_path = self.config.types.routes_path
772
+ if py_routes_path is None:
773
+ py_routes_path = self.config.types.output / "routes.json"
774
+ js_routes_path = (
775
+ (root / self.parsed_config.types_routes_path) if self.parsed_config.types_routes_path else None
776
+ )
777
+
778
+ if self.parsed_config.types_routes_path and _rel_to_root(py_routes_path, root) != _rel_to_root(
779
+ js_routes_path, root
780
+ ):
781
+ self.issues.append(
782
+ DoctorIssue(
783
+ check="TypeGen Routes Path Mismatch",
784
+ severity="warning",
785
+ message=f"Python '{py_routes_path}' != JS '{js_routes_path}'",
786
+ fix_hint=f"Update vite.config routesPath to '{py_routes_path}'",
787
+ auto_fixable=True,
788
+ context={"key": "routesPath", "expected": str(py_routes_path)},
789
+ )
790
+ )
791
+
792
+ def _check_typegen_flags(self) -> None:
793
+ """Check TypeGen flags when enabled on both sides.
794
+
795
+ Only compares flags that are explicitly set in JS to avoid warning on JS defaults.
796
+ """
797
+ if not self.parsed_config:
798
+ return
799
+
800
+ if not isinstance(self.config.types, TypeGenConfig):
801
+ return
802
+
803
+ if self.parsed_config.types_enabled:
804
+ py_zod = self.config.types.generate_zod
805
+ js_zod = self.parsed_config.types_generate_zod
806
+
807
+ if js_zod is not None and py_zod != js_zod:
808
+ self.issues.append(
809
+ DoctorIssue(
810
+ check="TypeGen generateZod Mismatch",
811
+ severity="warning",
812
+ message=f"Python generate_zod={py_zod} != JS generateZod={js_zod}",
813
+ fix_hint=f"Update vite.config generateZod to {str(py_zod).lower()}",
814
+ auto_fixable=True,
815
+ context={"key": "generateZod", "expected": str(py_zod).lower()},
816
+ )
817
+ )
818
+
819
+ py_sdk = self.config.types.generate_sdk
820
+ js_sdk = self.parsed_config.types_generate_sdk
821
+
822
+ if js_sdk is not None and py_sdk != js_sdk:
823
+ self.issues.append(
824
+ DoctorIssue(
825
+ check="TypeGen generateSdk Mismatch",
826
+ severity="warning",
827
+ message=f"Python generate_sdk={py_sdk} != JS generateSdk={js_sdk}",
828
+ fix_hint=f"Update vite.config generateSdk to {str(py_sdk).lower()}",
829
+ auto_fixable=True,
830
+ context={"key": "generateSdk", "expected": str(py_sdk).lower()},
831
+ )
832
+ )
833
+
834
+ def _check_plugin_spread(self) -> None:
835
+ """No-op check kept for backwards compatibility.
836
+
837
+ This check is intentionally disabled because Vite plugin arrays allow nested arrays; reliably detecting
838
+ missing spread introduces false positives.
839
+ """
840
+ return
841
+
842
+ def _check_dist_files(self) -> None:
843
+ """Verify JS plugin dist files exist."""
844
+ root = self.config.root_dir or Path.cwd()
845
+ pkg_paths = [
846
+ root / "node_modules" / "@litestar" / "vite-plugin" / "dist",
847
+ root / "node_modules" / "litestar-vite-plugin" / "dist",
848
+ ]
849
+
850
+ dist_path = next((p for p in pkg_paths if p.exists()), None)
851
+
852
+ if dist_path is None:
853
+ self.issues.append(
854
+ DoctorIssue(
855
+ check="Plugin Dist Missing",
856
+ severity="warning",
857
+ message="Could not find @litestar/vite-plugin dist files in node_modules",
858
+ fix_hint="Run `litestar assets install` to install frontend dependencies",
859
+ auto_fixable=False,
860
+ )
861
+ )
862
+ return
863
+
864
+ required_files = ["index.js", "install-hint.js", "litestar-meta.js"]
865
+ missing = [f for f in required_files if not (dist_path / "js" / f).exists()]
866
+
867
+ if missing:
868
+ self.issues.append(
869
+ DoctorIssue(
870
+ check="Corrupt Plugin Installation",
871
+ severity="error",
872
+ message=f"Missing required plugin files: {', '.join(missing)}",
873
+ fix_hint="Reinstall frontend dependencies with `litestar assets install` (or reinstall your package manager deps)",
874
+ auto_fixable=False,
875
+ )
876
+ )
877
+
878
+ def _check_node_modules(self) -> None:
879
+ """Check if node_modules directory exists."""
880
+ root = self.config.root_dir or Path.cwd()
881
+ node_modules = root / "node_modules"
882
+
883
+ if not node_modules.exists():
884
+ self.issues.append(
885
+ DoctorIssue(
886
+ check="Node Modules Missing",
887
+ severity="error",
888
+ message="node_modules directory not found",
889
+ fix_hint="Run `litestar assets install` to install frontend dependencies",
890
+ auto_fixable=False,
891
+ )
892
+ )
893
+ elif self.verbose:
894
+ console.print("[dim]✓ node_modules directory exists[/]")
895
+
896
+ def _check_vite_server_reachable(self) -> None:
897
+ """Check if Vite dev server is reachable (only in dev mode)."""
898
+ if not self.config.is_dev_mode:
899
+ return
900
+
901
+ host = self.config.host
902
+ port = self.config.port
903
+
904
+ if self.verbose:
905
+ console.print(f"[dim]Checking Vite server at {host}:{port}...[/]")
906
+
907
+ try:
908
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
909
+ sock.settimeout(2)
910
+ result = sock.connect_ex((host, port))
911
+ sock.close()
912
+
913
+ if result != 0:
914
+ self.issues.append(
915
+ DoctorIssue(
916
+ check="Vite Server Not Running",
917
+ severity="warning",
918
+ message=f"Cannot connect to Vite dev server at {host}:{port}",
919
+ fix_hint="Start the dev server with `litestar assets serve` (and your backend with `litestar run` if needed)",
920
+ auto_fixable=False,
921
+ )
922
+ )
923
+ elif self.verbose:
924
+ console.print(f"[dim]✓ Vite server reachable at {host}:{port}[/]")
925
+ except OSError as e:
926
+ self.issues.append(
927
+ DoctorIssue(
928
+ check="Vite Server Check Failed",
929
+ severity="warning",
930
+ message=f"Could not check Vite server: {e}",
931
+ fix_hint="Ensure Vite dev server is running",
932
+ auto_fixable=False,
933
+ )
934
+ )
935
+
936
+ def _check_hotfile_presence(self) -> None:
937
+ """Warn if hotfile is missing when it's required for dynamic proxy targets."""
938
+ if not self.config.is_dev_mode:
939
+ return
940
+
941
+ ext = self.config.runtime.external_dev_server
942
+ needs_hotfile = self.config.proxy_mode == "proxy" or (
943
+ isinstance(ext, ExternalDevServer) and ext.enabled and ext.target is None
944
+ )
945
+ if not needs_hotfile:
946
+ return
947
+
948
+ hot_path = self._resolve_to_root(self.config.bundle_dir) / self.config.hot_file
949
+ if not hot_path.exists():
950
+ self.issues.append(
951
+ DoctorIssue(
952
+ check="Hotfile Missing",
953
+ severity="warning",
954
+ message=f"Hotfile not found at {hot_path}",
955
+ fix_hint=(
956
+ "If you're running in proxy mode (SSR/external dev server), start the dev server with "
957
+ "`litestar assets serve` so it can write the hotfile. Otherwise, ignore this check or run "
958
+ "`litestar assets doctor` without `--runtime-checks`."
959
+ ),
960
+ auto_fixable=False,
961
+ )
962
+ )
963
+
964
+ def _check_manifest_presence(self) -> None:
965
+ """Ensure manifest exists in non-dev mode."""
966
+ if self.config.is_dev_mode:
967
+ return
968
+
969
+ candidates = self.config.candidate_manifest_paths()
970
+ if not any(path.exists() for path in candidates):
971
+ manifest_locations = " or ".join(str(path) for path in candidates)
972
+ self.issues.append(
973
+ DoctorIssue(
974
+ check="Manifest Missing",
975
+ severity="warning",
976
+ message=f"Manifest not found at {manifest_locations} (expected in production; ok during dev)",
977
+ fix_hint="Run `litestar assets build` before starting in production",
978
+ auto_fixable=False,
979
+ )
980
+ )
981
+
982
+ def _check_typegen_artifacts(self) -> None:
983
+ """Verify exported OpenAPI/routes when typegen is enabled."""
984
+ if not isinstance(self.config.types, TypeGenConfig):
985
+ return
986
+
987
+ openapi_path = self.config.types.openapi_path
988
+ if openapi_path is None:
989
+ openapi_path = self.config.types.output / "openapi.json"
990
+ routes_path = self.config.types.routes_path
991
+ if routes_path is None:
992
+ routes_path = self.config.types.output / "routes.json"
993
+
994
+ if not openapi_path.exists():
995
+ self.issues.append(
996
+ DoctorIssue(
997
+ check="OpenAPI Export Missing",
998
+ severity="warning",
999
+ message=f"{openapi_path} not found",
1000
+ fix_hint="Run litestar assets generate-types (or start the app with types enabled)",
1001
+ auto_fixable=False,
1002
+ )
1003
+ )
1004
+
1005
+ if not routes_path.exists():
1006
+ self.issues.append(
1007
+ DoctorIssue(
1008
+ check="Routes Export Missing",
1009
+ severity="warning",
1010
+ message=f"{routes_path} not found",
1011
+ fix_hint="Run litestar assets generate-types (or start the app with types enabled)",
1012
+ auto_fixable=False,
1013
+ )
1014
+ )
1015
+
1016
+ def _check_env_alignment(self) -> None:
1017
+ """Compare key env vars to active config to surface surprises."""
1018
+ env_mismatches: list[tuple[str, str, str]] = []
1019
+ comparisons = {
1020
+ "VITE_PORT": str(self.config.port),
1021
+ "VITE_HOST": self.config.host,
1022
+ "VITE_PROXY_MODE": self.config.proxy_mode,
1023
+ "VITE_PROTOCOL": self.config.protocol,
1024
+ }
1025
+ if self.config.base_url:
1026
+ comparisons["VITE_BASE_URL"] = self.config.base_url
1027
+
1028
+ for key, expected in comparisons.items():
1029
+ actual = os.getenv(key)
1030
+ if actual is None:
1031
+ continue
1032
+ if str(actual).rstrip("/") != str(expected).rstrip("/"):
1033
+ env_mismatches.append((key, str(actual), str(expected)))
1034
+
1035
+ if env_mismatches:
1036
+ mismatch_lines = ", ".join(f"{k}={a} (expected {e})" for k, a, e in env_mismatches)
1037
+ self.issues.append(
1038
+ DoctorIssue(
1039
+ check="Env / Config Mismatch",
1040
+ severity="warning",
1041
+ message=mismatch_lines,
1042
+ fix_hint="Unset conflicting env vars or align ViteConfig/runtime before running",
1043
+ auto_fixable=False,
1044
+ )
1045
+ )
1046
+
1047
+ def _print_config_snapshot(self, *, show_bridge: bool) -> None:
1048
+ """Print a detailed view of Python, .litestar.json, and vite.config settings."""
1049
+ if not self.parsed_config:
1050
+ return
1051
+
1052
+ root = self.config.root_dir or Path.cwd()
1053
+ python_cfg = {
1054
+ "root": str(root),
1055
+ "mode": self.config.mode,
1056
+ "asset_url": self.config.asset_url,
1057
+ "bundle_dir": str(self.config.bundle_dir),
1058
+ "resource_dir": str(self.config.resource_dir),
1059
+ "static_dir": str(self.config.static_dir),
1060
+ "manifest": self.config.manifest_name,
1061
+ "hot_file": self.config.hot_file,
1062
+ "proxy_mode": self.config.proxy_mode,
1063
+ "dev_mode": self.config.is_dev_mode,
1064
+ "host": self.config.host,
1065
+ "port": self.config.port,
1066
+ "executor": self.config.runtime.executor,
1067
+ "set_environment": self.config.set_environment,
1068
+ "types_enabled": isinstance(self.config.types, TypeGenConfig),
1069
+ }
1070
+
1071
+ js_cfg = {
1072
+ "has_litestar_config": self.parsed_config.has_litestar_config,
1073
+ "assetUrl": self.parsed_config.asset_url,
1074
+ "bundleDir": self.parsed_config.bundle_dir,
1075
+ "resourceDir": self.parsed_config.resource_dir,
1076
+ "staticDir": self.parsed_config.static_dir,
1077
+ "hotFile": self.parsed_config.hot_file,
1078
+ "inertiaMode": self.parsed_config.inertia_mode,
1079
+ "types": self.parsed_config.types_setting,
1080
+ "types.enabled": self.parsed_config.types_enabled,
1081
+ "types.output": self.parsed_config.types_output,
1082
+ }
1083
+
1084
+ blocks: list[Any] = [
1085
+ "[bold]Python (effective)[/]",
1086
+ Syntax(encode_json(python_cfg).decode(), "json", theme="ansi_dark", word_wrap=True),
1087
+ ]
1088
+
1089
+ if show_bridge and self.bridge_path is not None:
1090
+ bridge_obj: dict[str, Any] = {
1091
+ "path": str(self.bridge_path),
1092
+ "exists": self.bridge_path.exists(),
1093
+ "content": self.bridge_config or {},
1094
+ }
1095
+ blocks.extend([
1096
+ "[bold].litestar.json[/]",
1097
+ Syntax(encode_json(bridge_obj).decode(), "json", theme="ansi_dark", word_wrap=True),
1098
+ ])
1099
+
1100
+ blocks.extend([
1101
+ "[bold]vite.config (litestar plugin)[/]",
1102
+ Syntax(encode_json(js_cfg).decode(), "json", theme="ansi_dark", word_wrap=True),
1103
+ ])
1104
+
1105
+ if show_bridge and self.parsed_config.litestar_config_block:
1106
+ blocks.extend([
1107
+ "[bold]Extracted litestar({ ... }) block[/]",
1108
+ Syntax(self.parsed_config.litestar_config_block, "typescript", theme="ansi_dark", word_wrap=True),
1109
+ ])
1110
+
1111
+ console.print(Panel(Group(*blocks), title="Config snapshot", border_style="dim"))
1112
+
1113
+ def _print_report(self) -> None:
1114
+ """Print a table of detected issues."""
1115
+ if not self.issues:
1116
+ return
1117
+
1118
+ table = Table(show_header=True, header_style="bold magenta")
1119
+ table.add_column("Severity", style="dim")
1120
+ table.add_column("Check")
1121
+ table.add_column("Message")
1122
+ table.add_column("Fix Hint")
1123
+
1124
+ for issue in self.issues:
1125
+ severity_style = "red" if issue.severity == "error" else "yellow"
1126
+ table.add_row(f"[{severity_style}]{issue.severity.upper()}[/]", issue.check, issue.message, issue.fix_hint)
1127
+
1128
+ console.print(table)
1129
+
1130
+ def _apply_fixes(self, no_prompt: bool) -> bool:
1131
+ """Apply auto-fixes.
1132
+
1133
+ Returns:
1134
+ True if fixes were applied, otherwise False.
1135
+ """
1136
+ if not self.parsed_config:
1137
+ return False
1138
+
1139
+ fixable_issues = [i for i in self.issues if i.auto_fixable]
1140
+ if not fixable_issues:
1141
+ console.print("\n[yellow]No auto-fixable issues found.[/]")
1142
+ return False
1143
+
1144
+ console.print(f"\n[bold]Found {len(fixable_issues)} auto-fixable issues.[/]")
1145
+
1146
+ if not no_prompt and not Confirm.ask("Apply fixes?"):
1147
+ return False
1148
+
1149
+ content = self.parsed_config.content
1150
+
1151
+ will_edit_vite = self.vite_config_path is not None and any((i.context or {}).get("key") for i in fixable_issues)
1152
+ if will_edit_vite and self.vite_config_path is not None:
1153
+ backup_path = self.vite_config_path.with_suffix(self.vite_config_path.suffix + ".bak")
1154
+ backup_path.write_text(content)
1155
+ console.print(f"[dim]Created backup at {backup_path}[/]")
1156
+
1157
+ for issue in fixable_issues:
1158
+ context = issue.context
1159
+ if not context:
1160
+ continue
1161
+
1162
+ action = context.get("action")
1163
+ if action == "write_bridge":
1164
+ self._apply_write_bridge_fix()
1165
+ continue
1166
+
1167
+ key = context.get("key")
1168
+ expected = context.get("expected")
1169
+ if not key or expected is None:
1170
+ continue
1171
+
1172
+ content, updated = self._apply_vite_key_fix(content, key=key, expected=expected)
1173
+ if updated:
1174
+ console.print(f"[green]✓ Fixed {key}[/]")
1175
+ else:
1176
+ console.print(f"[red]✗ Failed to apply fix for {key} (pattern match failed)[/]")
1177
+
1178
+ if will_edit_vite and self.vite_config_path is not None:
1179
+ self.vite_config_path.write_text(content)
1180
+ console.print("\n[bold green]Fixes applied. Please verify configuration.[/]")
1181
+ return True