"""FastAPI-Vue Integration Tool. Create new FastAPI+Vue projects or patch existing ones with integrated build/dev systems. Usage: fastapi-vue-setup [project-dir] Set up or update FastAPI+Vue integration Options: --module-name NAME Python module name (auto-detected from pyproject.toml) --ports DEFAULT,VITE,DEV Port configuration (default: 3100,3100,3200) --dry Show what would be done without making changes -- ARGS Extra arguments forwarded to create-vue (e.g. -- --default) """ import argparse import ast import contextlib import hashlib import importlib.metadata import os import platform import re import shutil import subprocess import sys from pathlib import Path from textwrap import indent from typing import Literal import tomlkit version = importlib.metadata.version("fastapi-vue-setup") # Template directory TEMPLATE_DIR = Path(__file__).parent / "template" def print_boxed(text: str) -> None: """Print text in a Unicode rounded box.""" width = len(text) + 2 print(f"╭{'─' * width}╮") print(f"│ {text} │") print(f"╰{'─' * width}╯") def ruff_format_content( content: str, target_path: Path, *, mode: Literal["isort", "full"] = "full" ) -> str: """Format Python content using ruff with project settings. Writes to a temp file next to target, runs ruff check (import sorting) and optionally ruff format on it, reads back the result, and cleans up. Returns the formatted content, or original if ruff fails. mode='isort' only sorts imports; mode='full' also formats. """ temp_file = target_path.with_suffix(".new.py") try: temp_file.write_text(content, "UTF-8", newline="\n") if mode == "isort": # Sort imports only (ignore exit code) result = subprocess.run( # noqa: S603 [ # noqa: S607 "ruff", "check", "--select", "I", "--fix", "--output-format=concise", str(temp_file), ], cwd=target_path.parent, capture_output=True, check=False, ) if result.returncode != 0: print(result.stdout.decode()) return temp_file.read_text("UTF-8") # Full mode: fix all auto-fixable lint violations (ignore exit code) result = subprocess.run( # noqa: S603 [ # noqa: S607 "ruff", "check", "--ignore=EXE001,INP001,N999,CPY001", "--fix", "--output-format=concise", str(temp_file), ], cwd=target_path.parent, capture_output=True, check=False, ) if result.returncode != 0: print(result.stdout.decode()) # Then format (ignore exit code) result = subprocess.run( # noqa: S603 ["ruff", "format", str(temp_file)], # noqa: S607 cwd=target_path.parent, capture_output=True, check=False, ) if result.returncode != 0: print(result.stdout.decode()) return temp_file.read_text("UTF-8") except OSError: pass finally: with contextlib.suppress(Exception): temp_file.unlink(missing_ok=True) return content def uv_add_packages(packages: list[str], *, cwd: Path, group: str | None = None) -> None: """Add packages using uv. Uses --frozen so only pyproject.toml is edited, without locking or syncing - those happen in a single uv sync step after all changes. """ cmd = ["uv", "add", "-q", "--frozen"] if group: cmd.extend(["--group", group]) cmd.extend(packages) result = subprocess.run(cmd, cwd=cwd, check=False) # noqa: S603 if result.returncode != 0: label = f" ({group})" if group else "" print(f"⚠️ Failed to add{label} dependencies") # Default ports: (default, vite, dev) # If vite == dev, dev is incremented by 100 DEFAULT_PORTS = (3100, 3100, 3200) # Default health check path for devserver backend readiness check DEFAULT_HEALTH = "/api/health?from=devserver.py" # Marker comment indicating file can be auto-upgraded # Users should remove this line to prevent automatic updates UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup" # pyproject.toml additions for patched projects PYPROJECT_ADDITIONS = { "tool": { "hatch": { "build": { "packages": ["MODULE_NAME"], "artifacts": ["MODULE_NAME/frontend-build"], "targets": { "sdist": { "hooks": {"custom": {"path": "scripts/fastapi-vue/buildhook.py"}}, } }, "only-packages": True, } } }, } # Old build hook path that should be migrated to the new name OLD_BUILD_HOOK_PATH = "scripts/fastapi-vue/build-frontend.py" NEW_BUILD_HOOK_PATH = "scripts/fastapi-vue/buildhook.py" # Frontend instantiation block for patching existing apps FRONTEND_BLOCK = """ # Vue Frontend static files frontend = Frontend(Path(__file__).with_name("frontend-build")) """ # Lifespan block for patching apps that don't have one LIFESPAN_BLOCK = """ @asynccontextmanager async def lifespan(_app: FastAPI): \"\"\"Manage app startup and shutdown resources.\"\"\" await frontend.load() yield """ # TypeScript health check script for Vue components TS_HEALTH_CHECK_SCRIPT = """\ import { ref, onMounted } from 'vue' const backendStatus = ref<'checking' | 'connected' | 'error'>('checking') onMounted(async () => { try { const res = await fetch('/api/health?from=frontend') backendStatus.value = res.ok ? 'connected' : 'error' } catch { backendStatus.value = 'error' } }) """ # JavaScript health check script for Vue components JS_HEALTH_CHECK_SCRIPT = """\ import { ref, onMounted } from 'vue' const backendStatus = ref('checking') onMounted(async () => { try { const res = await fetch('/api/health?from=frontend') backendStatus.value = res.ok ? 'connected' : 'error' } catch { backendStatus.value = 'error' } }) """ # Status indicator template for Vue components STATUS_SPAN_TEMPLATE = """\ — FastAPI: ⏳ ✅ ❌ not reachable """ # Setup complete message template SETUP_COMPLETE_MESSAGE = """\ ## Development server: (live reloads, debug) CD_CMDuv run scripts/devserver.py ## Production build: CD_CMDuv build && uv run SCRIPT_NAME ## Release Python package, run anywhere: CD_CMDuv build && uv publish uvx SCRIPT_NAME # No Node required """ # ============================================================================= # Utility functions # ============================================================================= def parse_ports(ports_str: str | None) -> tuple[int, int, int]: """Parse comma-separated port string into (default, vite, dev) tuple. If dev == vite, dev is incremented by 100 to avoid conflicts. """ if not ports_str: return DEFAULT_PORTS parts = ports_str.split(",") if len(parts) == 1: default = int(parts[0]) vite = default dev = default + 100 elif len(parts) == 2: default = int(parts[0]) vite = int(parts[1]) dev = vite + 100 if vite == default else default + 100 elif len(parts) == 3: default = int(parts[0]) vite = int(parts[1]) dev = int(parts[2]) else: msg = f"Invalid ports format: {ports_str}" raise ValueError(msg) # Auto-adjust dev if it conflicts with vite if dev == vite: dev = vite + 100 return default, vite, dev def find_import_insertion_line(source: str) -> int: """Find line number (1-based) for inserting imports, after shebang/docstring.""" try: tree = ast.parse(source) except SyntaxError: return 2 if source.startswith("#!") else 1 # Find first import, or end of docstring if no imports for node in tree.body: if isinstance(node, (ast.Import, ast.ImportFrom)): return node.lineno if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)): break # Non-import, non-docstring statement # No imports found - insert after docstring or at line 1 if tree.body and isinstance(tree.body[0], ast.Expr): return tree.body[0].end_lineno + 1 return 2 if source.startswith("#!") else 1 def extract_existing_ports(project_dir: Path, main: Path) -> tuple[int, int, int] | None: """Extract existing port configuration from project files. Returns (default, vite, dev) or None if not found. """ default_port = None vite_port = None dev_port = None # Try to extract DEFAULT_PORT from the CLI main module if main.exists(): content = main.read_text("UTF-8") match = re.search(r"DEFAULT_PORT\s*=\s*(\d+)", content) if match: default_port = int(match.group(1)) # Try to extract ports from devserver.py devserver_file = project_dir / "scripts" / "devserver.py" if devserver_file.exists(): content = devserver_file.read_text("UTF-8") match = re.search(r"DEFAULT_VITE_PORT\s*=\s*(\d+)", content) if match: vite_port = int(match.group(1)) match = re.search(r"DEFAULT_DEV_PORT\s*=\s*(\d+)", content) if match: dev_port = int(match.group(1)) # Return only if we found at least one port if default_port is not None or vite_port is not None or dev_port is not None: return ( default_port or DEFAULT_PORTS[0], vite_port or DEFAULT_PORTS[1], dev_port or DEFAULT_PORTS[2], ) return None # Sentinel for "not found" in extract_existing_health _HEALTH_NOT_FOUND = object() def extract_existing_health(project_dir: Path) -> str | object: """Extract existing health path configuration from devserver.py. Returns: - The path string (may be empty to disable) - _HEALTH_NOT_FOUND sentinel if not found or file doesn't exist """ devserver_file = project_dir / "scripts" / "devserver.py" if not devserver_file.exists(): return _HEALTH_NOT_FOUND content = devserver_file.read_text("UTF-8") # Match HEALTH = "/path" or HEALTH = "" match = re.search(r'^HEALTH\s*=\s*"([^"]*)"', content, re.MULTILINE) if match: return match.group(1) return _HEALTH_NOT_FOUND def load_template(path: str) -> str: """Load a template file from the template directory.""" return (TEMPLATE_DIR / path).read_text("UTF-8") def find_module_name(project_dir: Path) -> str | None: """Auto-detect the Python module name from pyproject.toml.""" pyproject = project_dir / "pyproject.toml" if not pyproject.exists(): return None data = tomlkit.parse(pyproject.read_text("UTF-8")) if "project" in data and "name" in data["project"]: name = data["project"]["name"] return name.replace("-", "_") return None def find_fastapi_app(module_dir: Path, project_dir: Path | None = None) -> tuple[Path, str] | None: """Find the FastAPI app in a module directory. Returns (file_path, app_variable_name) or None if not found. Search order: 1. Common app files in module_dir (app.py, main.py, etc.) 2. All .py files in module_dir 3. Subpackage indicated by CLI entrypoint in pyproject.toml 4. Follow re-exports in __init__.py files """ # Common app file names to check first candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"] # Check common names first for name in candidates: path = module_dir / name if path.exists(): result = _find_app_in_file(path) if result: return path, result # Then check all .py files in module_dir for path in module_dir.glob("*.py"): if path.name not in candidates: result = _find_app_in_file(path) if result: return path, result # Try to find app via CLI entrypoint in pyproject.toml if project_dir: result = _find_app_via_entrypoint(module_dir, project_dir) if result: return result return None def _find_app_via_entrypoint(module_dir: Path, project_dir: Path) -> tuple[Path, str] | None: """Find FastAPI app by following the CLI entrypoint in pyproject.toml. If pyproject.toml has a script like `myapp = "myapp.subpkg.__main__:main"`, look in myapp/subpkg/ for the app (checking __init__.py exports and common files). """ pyproject = project_dir / "pyproject.toml" if not pyproject.exists(): return None try: data = tomlkit.parse(pyproject.read_text("UTF-8")) except (OSError, ValueError): return None scripts = data.get("project", {}).get("scripts", {}) if not scripts: return None module_name = module_dir.name # Find script entries that reference this module for entry in scripts.values(): if not isinstance(entry, str): continue # Parse entry like "module.subpkg.__main__:main" if ":" not in entry: continue module_path, _ = entry.rsplit(":", 1) parts = module_path.split(".") # Check if this entry starts with our module if not parts or parts[0] != module_name: continue # If there's a subpackage (e.g., module.fastapi.__main__), check there if len(parts) >= 2: # Build path to subpackage (exclude __main__ or similar) subpkg_parts = [p for p in parts[1:] if not p.startswith("_")] if subpkg_parts: subpkg_dir = module_dir / "/".join(subpkg_parts) if subpkg_dir.is_dir(): result = _find_app_in_subpackage(subpkg_dir) if result: return result return None def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None: """Find FastAPI app in a subpackage, following __init__.py exports.""" # First check __init__.py for re-exports like `from .mainapp import app` init_file = subpkg_dir / "__init__.py" if init_file.exists(): result = _follow_init_reexport(init_file, subpkg_dir) if result: return result # Check common app file names in subpackage for name in ["app.py", "main.py", "mainapp.py", "server.py", "api.py"]: path = subpkg_dir / name if path.exists(): result = _find_app_in_file(path) if result: return path, result return None def _migrate_devmode_in_main(content: str) -> str | None: """Spot-patch the pre-1.6 DEVMODE mechanism to the FASTAPI_VUE env prefix. Replaces `DEVMODE = os.getenv("PREFIX_DEV") == "1"` with `os.environ["FASTAPI_VUE"] = "PREFIX"` and remaining DEVMODE references with env.dev, ensuring env is imported from fastapi_vue. Returns the patched content, or None if there was nothing to patch. """ match = re.search( r"^DEVMODE\s*=\s*os\.getenv\(\s*[\"']([A-Za-z0-9_]+)_DEV[\"']\s*\)\s*==\s*[\"']1[\"']", content, re.MULTILINE, ) if not match: return None content = ( content[: match.start()] + f'os.environ["FASTAPI_VUE"] = "{match.group(1)}"' + content[match.end() :] ) # Replace every remaining standalone DEVMODE reference (as in app.py # migration, string literals are an accepted risk) content = re.sub(r"(?= len(lines): content = content.rstrip("\n") + "\n" + import_text else: content = "".join(lines[:insert_idx]) + import_text + "".join(lines[insert_idx:]) return content def _patch_main_devmode(path: Path, *, dry: bool) -> str | None: """Apply _migrate_devmode_in_main to a main module in place, if needed. Done in place even without the auto-upgrade marker: the marker guards full-file overwrites, while leaving this change to a .new.py merge would silently break dev mode for every customized pre-1.6 main. Returns the migrated content if the module was (or would be) patched, None if there was nothing to patch. """ content = path.read_text("UTF-8") migrated = _migrate_devmode_in_main(content) if migrated is None: return None migrated = ruff_format_content(migrated, path, mode="isort") if dry: print(f"✅ Would patch {path} (DEVMODE → FASTAPI_VUE)") return migrated path.write_text(migrated, "UTF-8", newline="\n") print(f"✅ Patched {path} (DEVMODE → FASTAPI_VUE)") return migrated def _add_env_prefix_to_main(content: str) -> str: """Add FASTAPI_VUE environment prefix setup to an existing main module.""" lines = content.splitlines() # Check if os is imported has_os_import = any("import os" in line for line in lines) # Find the first import line insert_idx = 0 for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith(("import ", "from ")): insert_idx = i + 1 elif stripped and not stripped.startswith("#"): break # Insert imports and env setup after existing imports new_lines = [] if not has_os_import: new_lines.append("import os") new_lines.extend( [ "", "# Added by fastapi-vue-setup", 'os.environ["FASTAPI_VUE"] = "ENVPREFIX"', "", ] ) lines[insert_idx:insert_idx] = new_lines return "\n".join(lines) def _find_existing_cli_module_path(project_dir: Path, module_name: str) -> str | None: """Check if pyproject.toml already has a CLI entrypoint for this module. Returns the module path (e.g., 'module.subpkg.__main__') if found, None otherwise. """ pyproject = project_dir / "pyproject.toml" if not pyproject.exists(): return None try: data = tomlkit.parse(pyproject.read_text("UTF-8")) except (OSError, ValueError): return None scripts = data.get("project", {}).get("scripts", {}) if not scripts: return None # Look for any script that references our module for entry in scripts.values(): if isinstance(entry, str) and entry.startswith(f"{module_name}.") and ":" in entry: # Extract module path from "module.subpkg.__main__:main" module_path, _ = entry.rsplit(":", 1) return module_path return None def _follow_init_reexport(init_file: Path, subpkg_dir: Path) -> tuple[Path, str] | None: """Follow a re-export in __init__.py to find the actual app file. Looks for patterns like: - from .mainapp import app - from module.subpkg.mainapp import app """ try: content = init_file.read_text("UTF-8") except OSError: return None # Look for: from .module import app (or similar variable names) # Pattern matches: from .mainapp import app, application, etc. pattern = r"from\s+\.(\w+)\s+import\s+(\w+)" for match in re.finditer(pattern, content): module_name, var_name = match.groups() if var_name.lower() in ("app", "application", "api"): target_file = subpkg_dir / f"{module_name}.py" if target_file.exists(): # Verify the app is actually there app_var = _find_app_in_file(target_file) if app_var: return target_file, app_var # Also check for absolute imports: from pkg.subpkg.module import app abs_pattern = r"from\s+[\w.]+\.(\w+)\s+import\s+(\w+)" for match in re.finditer(abs_pattern, content): module_name, var_name = match.groups() if var_name.lower() in ("app", "application", "api"): target_file = subpkg_dir / f"{module_name}.py" if target_file.exists(): app_var = _find_app_in_file(target_file) if app_var: return target_file, app_var return None def _find_app_in_file(path: Path) -> str | None: """Find FastAPI app variable name in a file.""" try: content = path.read_text("UTF-8") except OSError: return None # Look for FastAPI() instantiation patterns # Matches: app = FastAPI(...) or application = FastAPI(...) pattern = r"^(\w+)\s*=\s*FastAPI\s*\(" for match in re.finditer(pattern, content, re.MULTILINE): return match.group(1) return None def render_template(template: str, **kwargs: str) -> str: """Render a template, replacing KEY with value.""" result = template for key, value in kwargs.items(): result = result.replace(key, value) return result def needs_app_migration(project_dir: Path) -> bool: """Check if the project was set up with fastapi-vue older than 1.6. Those versions patched app.py with a DEVMODE import from the main module; 1.6+ uses env.dev from fastapi_vue instead. Must be called before the dependency step rewrites the fastapi-vue requirement in pyproject.toml. """ pyproject = project_dir / "pyproject.toml" if not pyproject.exists(): return False data = tomlkit.parse(pyproject.read_text("UTF-8")) for dep in data.get("project", {}).get("dependencies", []): match = re.match(r"\s*fastapi-vue(?:\[[^\]]*\])?\s*(.*)", str(dep)) if match: version = re.search(r"(\d+)\.(\d+)", match.group(1)) return version is not None and (int(version[1]), int(version[2])) < (1, 6) return False def patch_app_file( path: Path, main_module_path: str, app_var: str, *, migrate: bool = False, dry: bool = False ) -> bool: """Patch an existing app.py with frontend integration. Inserts imports at top (ruff will sort them), route at bottom, and tries to patch lifespan with frontend.load(). With migrate=True, pre-1.6 patching (DEVMODE import from the main module) is first rewritten to the current format (env.dev). Returns True if patched, False if already patched or failed. """ if not path.exists(): print(f"❌ Cannot patch {path} - file not found") return False original_content = path.read_text("UTF-8") content = original_content # Migrate pre-1.6 patching to the current format: DEVMODE via # fastapi_vue.env (the Frontend import stays as-is) if migrate: old_import = f"from {main_module_path} import DEVMODE" if old_import in content: # The import sort at the end merges this with any existing # `from fastapi_vue import Frontend` line content = content.replace(old_import, "from fastapi_vue import env") # Replace every remaining standalone DEVMODE reference (not just # the debug= parameter); it may also appear inside string literals, # but that's an accepted risk over AST rewriting content = re.sub(r"(?= len(lines): # Append at end content = content.rstrip("\n") + "\n" + import_text else: # Insert at the found position content = "".join(lines[:insert_idx]) + import_text + "".join(lines[insert_idx:]) # Insert FRONTEND_BLOCK after last import (only if Frontend wasn't already there) if not has_frontend: lines = content.split("\n") last_import_idx = 0 for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith(("import ", "from ")): last_import_idx = i elif stripped and not stripped.startswith("#") and last_import_idx > 0: break lines.insert(last_import_idx + 1, FRONTEND_BLOCK) content = "\n".join(lines) # Append route at end (only if not already present) if route_line not in content: lines = content.split("\n") lines.append("") lines.append("# Serve the Vue frontend (needs to be last if SPA catch-all is used)") lines.append(route_line) content = "\n".join(lines) # Try to patch FastAPI() call with debug=env.dev if no debug arg exists if not has_debug_arg: fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)" for match in re.finditer(fastapi_pattern, content, re.DOTALL): args = match.group(2) if "debug" not in args: # Add debug=env.dev as last argument new_args = (f"{args}, " if args.strip() else "") + "debug=env.dev" content = ( content[: match.start()] + match.group(1) + new_args + ")" + content[match.end() :] ) break # Only patch first FastAPI() call # Try to patch lifespan function - insert await frontend.load() before yield lifespan_patched = "await frontend.load()" in content # Look for yield inside an async def lifespan function # Find the yield statement and insert before it if not lifespan_patched: yield_pattern = r"^([ \t]+)(yield\b)" yield_match = re.search(yield_pattern, content, re.MULTILINE) if yield_match: ws = yield_match.group(1) insert_pos = yield_match.start() load_code = f"{ws}await frontend.load()\n" content = content[:insert_pos] + load_code + content[insert_pos:] lifespan_patched = True # No lifespan at all: create one and wire it into FastAPI() if not lifespan_patched and f"@{app_var}.on_event" not in content: # Add contextlib import if "from contextlib import asynccontextmanager" not in content: insert_line = find_import_insertion_line(content) lines = content.splitlines(keepends=True) insert_idx = insert_line - 1 import_text = "from contextlib import asynccontextmanager\n" if insert_idx >= len(lines): content = content.rstrip("\n") + "\n" + import_text else: content = "".join(lines[:insert_idx]) + import_text + "".join(lines[insert_idx:]) # Insert lifespan block before the FastAPI() call fastapi_line_pattern = r"^(\w+\s*=\s*FastAPI\s*\()" fastapi_match = re.search(fastapi_line_pattern, content, re.MULTILINE) if fastapi_match: content = ( content[: fastapi_match.start()] + LIFESPAN_BLOCK.lstrip("\n") + "\n" + content[fastapi_match.start() :] ) # Add lifespan=lifespan to FastAPI() call fastapi_pattern = r"(\w+\s*=\s*FastAPI\s*\()([^)]*)\)" fastapi_match = re.search(fastapi_pattern, content, re.DOTALL) if fastapi_match and "lifespan" not in fastapi_match.group(2): args = fastapi_match.group(2) new_args = f"{args}, lifespan=lifespan" if args.strip() else "lifespan=lifespan" content = ( content[: fastapi_match.start()] + fastapi_match.group(1) + new_args + ")" + content[fastapi_match.end() :] ) lifespan_patched = True # Check if content actually changed if content == original_content: print(f"⚠️ Skipping {path} (no changes needed)") return False if dry: print(f"✅ Would patch {path}") return True # Sort imports only (avoid full formatting of user code) content = ruff_format_content(content, path, mode="isort") path.write_text(content, "UTF-8", newline="\n") print(f"✅ Patched {path}") if not lifespan_patched: # Check if they're using deprecated on_event if f"@{app_var}.on_event" in content: print() print("⚠️ Your app uses the deprecated @app.on_event decorator.") print(" Please migrate to the lifespan pattern and add:") print(" await frontend.load()") print() else: print() print("⚠️ Could not find lifespan function to patch.") print(" Add this to your app's lifespan function:") print(" await frontend.load()") print() return True def patch_vite_config( path: Path, *, dry: bool = False, ) -> bool: """Patch an existing vite.config.js/ts by adding fastapi-vue plugin. This approach is cleaner than inline patching - we just add an import and include the plugin in the plugins array. """ if not path.exists(): print(f"❌ Cannot patch {path} - file not found") return False original_content = path.read_text("UTF-8") marker = "vite-plugin-fastapi" if marker in original_content: print(f"✔️ {path} (already patched)") return False # Add import for the plugin at the top (after other imports) import_line = f"import fastapiVue from './{marker}.js'" lines = original_content.split("\n") new_lines = [] import_inserted = False for i, line in enumerate(lines): new_lines.append(line) # Insert after the last import line before non-import content if not import_inserted: stripped = line.strip() if stripped.startswith(("import ", "from ")) and i + 1 < len(lines): next_stripped = lines[i + 1].strip() if not next_stripped.startswith("import ") and not next_stripped.startswith( "from " ): new_lines.append(import_line) import_inserted = True if not import_inserted: # No imports found, add at top new_lines.insert(0, import_line) content = "\n".join(new_lines) # Add fastapiVue to plugins array # Look for plugins: [ and add fastapiVue() as first entry plugins_pattern = r"(plugins\s*:\s*\[)" match = re.search(plugins_pattern, content) if match: insert_pos = match.end() content = content[:insert_pos] + "\n fastapiVue()," + content[insert_pos:] else: print(f"⚠️ Skipping {path} (no plugins array found)") return False # Check if content actually changed if content == original_content: print(f"ℹ️ Skipping {path} (no changes needed)") return False if dry: print(f"✅ Would patch {path}") return True path.write_text(content, "UTF-8", newline="\n") print(f"✅ Patched {path}") return True def patch_frontend_health_check(frontend_dir: Path, *, dry: bool = False) -> bool: """Patch Vue app to include FastAPI backend health check. Tries HelloWorld.vue first (full demo), then falls back to App.vue (minimal). Works with both JS and TS versions created by create-vue. """ # Find the file to patch - prefer HelloWorld.vue, fall back to App.vue target_file = None # Try HelloWorld.vue first (full demo app) hello_world = frontend_dir / "src" / "components" / "HelloWorld.vue" if hello_world.exists(): target_file = hello_world else: # Try finding HelloWorld.vue elsewhere for path in frontend_dir.glob("src/**/HelloWorld.vue"): target_file = path break # Fall back to App.vue (minimal app) if target_file is None: app_vue = frontend_dir / "src" / "App.vue" if app_vue.exists(): target_file = app_vue if target_file is None: print("ℹ️ No default App.vue found to patch, not adding /api/health check") return False original_content = target_file.read_text("UTF-8") # Check if already patched if "/api/health" in original_content: print(f"✔️ {target_file} (already patched)") return False content = original_content # Detect if TypeScript (has lang="ts" in script tag) is_typescript = 'lang="ts"' in content # Build the script content based on JS/TS script_addition = TS_HEALTH_CHECK_SCRIPT if is_typescript else JS_HEALTH_CHECK_SCRIPT # Insert script addition before script_end_match = re.search(r"", content) if not script_end_match: print(f"⚠️ Skipping {target_file} (no tag found)") return False insert_pos = script_end_match.start() content = content[:insert_pos] + script_addition + content[insert_pos:] # Insert status inline - find the best place based on file type # For HelloWorld.vue: insert before # For App.vue (minimal): only patch if it's the default "You did it!" template if "HelloWorld" in str(target_file): # Insert before closing h3_close = content.find(" ") if h3_close == -1: print(f"⚠️ Skipping {target_file} (no tag found for status insertion)") return False before, after = content[:h3_close], content[h3_close:] content = f"{before}{indent(STATUS_SPAN_TEMPLATE, ' ')}{after}" else: # Minimal App.vue - only patch if it contains the default welcome message if "