Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9ddf7dc5 | ||
|
|
b189ae66e7 | ||
|
|
4156eb2db3 | ||
|
|
3f6559b2db | ||
|
|
71d4e053a7 | ||
|
|
2dec331afb | ||
|
|
64f43df3e3 | ||
|
|
6ccba2be9f | ||
|
|
2b735528ab | ||
|
|
e6170549f0 |
@@ -1,6 +1,6 @@
|
|||||||
# fastapi-vue-setup
|
# fastapi-vue-setup
|
||||||
|
|
||||||
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. The Python package will not need any JS runtime because it includes a prebuilt Vue frontend in it. For development (Vite and FastAPI auto reloads) and building the package one of npm, deno or bun is required (npm recommended due to bugs in deno and bun).
|
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. The Python package will not need any JS runtime because it includes a prebuilt Vue frontend in it. For development (Vite and FastAPI auto reloads) and building the package one of npm, deno or bun is required (node is recommended due to bugs in deno and bun).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -61,17 +61,18 @@ Options:
|
|||||||
|
|
||||||
## Port Configuration
|
## Port Configuration
|
||||||
|
|
||||||
The tool uses three distinct ports:
|
In development, you access the Vite dev server at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Ports and hosts of Vite and FastAPI are configurable by `devserver.py` arguments.
|
||||||
|
|
||||||
| Port | Purpose | Used by |
|
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument with defaults set in `__main__.py`
|
||||||
| ---- | --------------------- | ------------------------------ |
|
|
||||||
| 5173 | Vite dev server (HMR) | `npm run dev` via devserver.py |
|
|
||||||
| 5180 | FastAPI in dev mode | uvicorn via devserver.py |
|
|
||||||
| 5080 | Production server | `uv run my-app` |
|
|
||||||
|
|
||||||
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Configurable with `devserver.py` arguments.
|
## Main CLI
|
||||||
|
|
||||||
|
If your project didn't already have `__main__.py`, we create one that runs the FastAPI app with richer configuration than what the FastAPI CLI offers. Running your module starts it in production mode, and optionally host:port may be given as argument to specify where it listens.
|
||||||
|
|
||||||
|
If you are running behind a reverse proxy like [Caddy](https://caddyserver.com/) on localhost, your app will trust the proxy headers it sends. However, if you need to configure another proxy host or IP, set `FORWARDED_ALLOW_IPS` env variable before running the server.
|
||||||
|
|
||||||
|
The devserver script depends on this CLI entry for running the backend. You will have to modify the `devserver.py` script if your app has its own incompatible main module. Note that we set FastAPI debug mode and Uvicorn reload when configured via `FASTAPI_VUE_BACKEND_URL` env variable (set by `devserver.py`), while for normal production use these stay disabled. The same variable also controls static files serving (disabled in dev mode).
|
||||||
|
|
||||||
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument.
|
|
||||||
|
|
||||||
## Vite Plugin Configuration
|
## Vite Plugin Configuration
|
||||||
|
|
||||||
@@ -96,26 +97,27 @@ The plugin reads environment `FASTAPI_VUE_BACKEND_URL` (default: `http://localho
|
|||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
After patching, your project will have:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
my-app/
|
my-app/
|
||||||
├── frontend/ # Vue application
|
├── frontend/ # Vue application
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ ├── vite.config.ts # Builds to ../my_app/frontend-build
|
│ ├── vite-plugin-fastapi.js
|
||||||
|
│ ├── vite.config.js
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── my_app/ # Python package
|
├── my_app/ # Python module (files included in sdist)
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── __main__.py # CLI entrypoint
|
│ ├── __main__.py # CLI entrypoint
|
||||||
│ ├── app.py # FastAPI application
|
│ ├── app.py # FastAPI application
|
||||||
│ └── frontend-build/ # Built frontend (gitignored)
|
│ └── frontend-build/ # Built frontend (gitignored)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── devserver.py # Development server
|
│ ├── devserver.py # CLI dev server (only in source tree)
|
||||||
│ └── fastapi-vue/ # Build utilities
|
│ └── fastapi-vue/
|
||||||
└── pyproject.toml # Project configuration
|
│ ├── build-frontend.py
|
||||||
|
│ └── util.py
|
||||||
|
└── pyproject.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
The script finds your existing fastpi app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection.
|
The project directory tree looks roughly like this after project creation or patching. The script finds your existing app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. New Python and Vue projects are created automatically if none exist.
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
@@ -130,6 +132,6 @@ uv build
|
|||||||
uv run my-app
|
uv run my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
# Frontend serving
|
## Frontend serving
|
||||||
|
|
||||||
Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend file. Refer to that package's documentation for further configuration.
|
Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend files. Refer to that package's documentation for further configuration.
|
||||||
|
|||||||
+534
-222
@@ -15,14 +15,18 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from textwrap import indent
|
||||||
|
|
||||||
import tomli_w
|
import tomlkit
|
||||||
|
|
||||||
# Template directory
|
# Template directory
|
||||||
TEMPLATE_DIR = Path(__file__).parent / "template"
|
TEMPLATE_DIR = Path(__file__).parent / "template"
|
||||||
|
|
||||||
|
# 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.toml additions for patched projects
|
||||||
PYPROJECT_ADDITIONS = {
|
PYPROJECT_ADDITIONS = {
|
||||||
"tool": {
|
"tool": {
|
||||||
@@ -44,6 +48,71 @@ PYPROJECT_ADDITIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Frontend instantiation block for patching existing apps
|
||||||
|
FRONTEND_BLOCK = """
|
||||||
|
# Vue Frontend static files
|
||||||
|
frontend = Frontend(
|
||||||
|
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
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')
|
||||||
|
backendStatus.value = res.ok ? 'connected' : 'error'
|
||||||
|
} catch {
|
||||||
|
backendStatus.value = 'error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Status indicator template for Vue components
|
||||||
|
STATUS_SPAN_TEMPLATE = """\
|
||||||
|
<span style="white-space: nowrap">
|
||||||
|
— FastAPI:
|
||||||
|
<span v-if="backendStatus === 'checking'">⏳</span>
|
||||||
|
<span v-else-if="backendStatus === 'connected'">✅</span>
|
||||||
|
<span v-else>❌ not reachable</span>
|
||||||
|
</span>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Setup complete message template
|
||||||
|
SETUP_COMPLETE_MESSAGE = """
|
||||||
|
Next steps:
|
||||||
|
|
||||||
|
1. Build for production:
|
||||||
|
CD_CMDuv build
|
||||||
|
|
||||||
|
2. Start development server:
|
||||||
|
CD_CMDuv run scripts/devserver.py
|
||||||
|
|
||||||
|
3. Run production server:
|
||||||
|
CD_CMDuv run SCRIPT_NAME
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Utility functions
|
# Utility functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -60,8 +129,7 @@ def find_module_name(project_dir: Path) -> str | None:
|
|||||||
if not pyproject.exists():
|
if not pyproject.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with open(pyproject, "rb") as f:
|
data = tomlkit.parse(pyproject.read_text())
|
||||||
data = tomllib.load(f)
|
|
||||||
|
|
||||||
if "project" in data and "name" in data["project"]:
|
if "project" in data and "name" in data["project"]:
|
||||||
name = data["project"]["name"]
|
name = data["project"]["name"]
|
||||||
@@ -70,10 +138,18 @@ def find_module_name(project_dir: Path) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | 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.
|
"""Find the FastAPI app in a module directory.
|
||||||
|
|
||||||
Returns (file_path, app_variable_name) or None if not found.
|
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
|
# Common app file names to check first
|
||||||
candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"]
|
candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"]
|
||||||
@@ -86,13 +162,153 @@ def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | None:
|
|||||||
if result:
|
if result:
|
||||||
return path, result
|
return path, result
|
||||||
|
|
||||||
# Then check all .py files
|
# Then check all .py files in module_dir
|
||||||
for path in module_dir.glob("*.py"):
|
for path in module_dir.glob("*.py"):
|
||||||
if path.name not in candidates:
|
if path.name not in candidates:
|
||||||
result = _find_app_in_file(path)
|
result = _find_app_in_file(path)
|
||||||
if result:
|
if result:
|
||||||
return path, 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())
|
||||||
|
except Exception:
|
||||||
|
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 script_name, entry in scripts.items():
|
||||||
|
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 _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str | None:
|
||||||
|
"""Check if pyproject.toml already has a CLI entrypoint for this module.
|
||||||
|
|
||||||
|
Returns the entrypoint string if found, None otherwise.
|
||||||
|
"""
|
||||||
|
pyproject = project_dir / "pyproject.toml"
|
||||||
|
if not pyproject.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = tomlkit.parse(pyproject.read_text())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scripts = data.get("project", {}).get("scripts", {})
|
||||||
|
if not scripts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look for any script that references our module
|
||||||
|
for script_name, entry in scripts.items():
|
||||||
|
if isinstance(entry, str) and entry.startswith(f"{module_name}."):
|
||||||
|
return f"{script_name} = \"{entry}\""
|
||||||
|
|
||||||
|
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()
|
||||||
|
except Exception:
|
||||||
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -134,31 +350,17 @@ def patch_app_file(
|
|||||||
print(f"❌ Cannot patch {path} - file not found")
|
print(f"❌ Cannot patch {path} - file not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
content = path.read_text()
|
original_content = path.read_text()
|
||||||
marker = "from fastapi_vue import Frontend"
|
marker = "from fastapi_vue import Frontend"
|
||||||
|
|
||||||
if marker in content:
|
if marker in original_content:
|
||||||
print(f"⚠️ Skipping {path} (already patched)")
|
print(f"✔️ {path} (already patched)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print(f"[DRY RUN] Would patch {path}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Find where to insert the import (after other imports)
|
# Find where to insert the import (after other imports)
|
||||||
lines = content.split("\n")
|
lines = original_content.split("\n")
|
||||||
import_line = "from fastapi_vue import Frontend"
|
import_line = "from fastapi_vue import Frontend"
|
||||||
|
|
||||||
# Frontend instantiation block - use module_name for the frontend-build path
|
|
||||||
frontend_block = """
|
|
||||||
# Frontend static file server - configure options here
|
|
||||||
frontend = Frontend(
|
|
||||||
Path(__file__).parent / "frontend-build",
|
|
||||||
spa=True,
|
|
||||||
favicon="/assets/favicon",
|
|
||||||
cached=["/assets/"],
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
route_line = f'frontend.route({app_var}, "/")'
|
route_line = f'frontend.route({app_var}, "/")'
|
||||||
|
|
||||||
# Find last import line and check if pathlib is imported
|
# Find last import line and check if pathlib is imported
|
||||||
@@ -174,44 +376,46 @@ frontend = Frontend(
|
|||||||
# Stop at first non-import, non-comment, non-empty line after imports
|
# Stop at first non-import, non-comment, non-empty line after imports
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Check if we found imports to insert after
|
||||||
|
if last_import_idx == 0 and not lines[0].strip().startswith(("import ", "from ")):
|
||||||
|
print(f"⚠️ Skipping {path} (no imports found to patch)")
|
||||||
|
return False
|
||||||
|
|
||||||
# Insert imports after last import, then frontend instantiation
|
# Insert imports after last import, then frontend instantiation
|
||||||
if not has_pathlib:
|
if not has_pathlib:
|
||||||
lines.insert(last_import_idx + 1, "from pathlib import Path")
|
lines.insert(last_import_idx + 1, "from pathlib import Path")
|
||||||
last_import_idx += 1
|
last_import_idx += 1
|
||||||
lines.insert(last_import_idx + 1, import_line)
|
lines.insert(last_import_idx + 1, import_line)
|
||||||
lines.insert(last_import_idx + 2, frontend_block)
|
lines.insert(last_import_idx + 2, FRONTEND_BLOCK)
|
||||||
|
|
||||||
# Append route at end
|
# Append route at end
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(route_line)
|
lines.append(route_line)
|
||||||
content = "\n".join(lines)
|
content = "\n".join(lines)
|
||||||
|
|
||||||
# Try to patch lifespan function
|
# Try to patch lifespan function - insert await frontend.load() before yield
|
||||||
lifespan_patched = False
|
lifespan_patched = False
|
||||||
|
|
||||||
# Look for async def lifespan pattern and insert after the opening (and docstring if present)
|
# Look for yield inside an async def lifespan function
|
||||||
lifespan_pattern = r"(async\s+def\s+lifespan\s*\([^)]*\)\s*(?:->.*?)?:\s*\n)"
|
# Find the yield statement and insert before it
|
||||||
match = re.search(lifespan_pattern, content)
|
yield_pattern = r"^([ \t]+)(yield\b)"
|
||||||
if match:
|
yield_match = re.search(yield_pattern, content, re.MULTILINE)
|
||||||
insert_pos = match.end()
|
if yield_match:
|
||||||
rest = content[insert_pos:]
|
indent = yield_match.group(1)
|
||||||
|
insert_pos = yield_match.start()
|
||||||
# Detect indentation from the next line
|
|
||||||
indent_match = re.match(r"([ \t]*)", rest)
|
|
||||||
indent = (
|
|
||||||
indent_match.group(1) if indent_match and indent_match.group(1) else " "
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if there's a docstring and skip past it
|
|
||||||
docstring_pattern = r'^([ \t]*)("""[\s\S]*?"""|\'\'\'\'[\s\S]*?\'\'\')\s*\n'
|
|
||||||
docstring_match = re.match(docstring_pattern, rest)
|
|
||||||
if docstring_match:
|
|
||||||
insert_pos += docstring_match.end()
|
|
||||||
|
|
||||||
load_code = f"{indent}await frontend.load()\n"
|
load_code = f"{indent}await frontend.load()\n"
|
||||||
content = content[:insert_pos] + load_code + content[insert_pos:]
|
content = content[:insert_pos] + load_code + content[insert_pos:]
|
||||||
lifespan_patched = True
|
lifespan_patched = True
|
||||||
|
|
||||||
|
# Check if content actually changed
|
||||||
|
if content == original_content:
|
||||||
|
print(f"⚠️ Skipping {path} (no changes needed)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"[DRY RUN] Would patch {path}")
|
||||||
|
return True
|
||||||
|
|
||||||
path.write_text(content)
|
path.write_text(content)
|
||||||
print(f"✅ Patched {path}")
|
print(f"✅ Patched {path}")
|
||||||
|
|
||||||
@@ -247,21 +451,17 @@ def patch_vite_config(
|
|||||||
print(f"❌ Cannot patch {path} - file not found")
|
print(f"❌ Cannot patch {path} - file not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
content = path.read_text()
|
original_content = path.read_text()
|
||||||
marker = "vite-plugin-fastapi"
|
marker = "vite-plugin-fastapi"
|
||||||
|
|
||||||
if marker in content:
|
if marker in original_content:
|
||||||
print(f"⚠️ Skipping {path} (already patched)")
|
print(f"✔️ {path} (already patched)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print(f"[DRY RUN] Would patch {path}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Add import for the plugin at the top (after other imports)
|
# Add import for the plugin at the top (after other imports)
|
||||||
import_line = f"import fastapiVue from './{marker}.js'"
|
import_line = f"import fastapiVue from './{marker}.js'"
|
||||||
|
|
||||||
lines = content.split("\n")
|
lines = original_content.split("\n")
|
||||||
new_lines = []
|
new_lines = []
|
||||||
import_inserted = False
|
import_inserted = False
|
||||||
|
|
||||||
@@ -293,6 +493,18 @@ def patch_vite_config(
|
|||||||
if match:
|
if match:
|
||||||
insert_pos = match.end()
|
insert_pos = match.end()
|
||||||
content = content[:insert_pos] + "\n fastapiVue()," + content[insert_pos:]
|
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_run:
|
||||||
|
print(f"[DRY RUN] Would patch {path}")
|
||||||
|
return True
|
||||||
|
|
||||||
path.write_text(content)
|
path.write_text(content)
|
||||||
print(f"✅ Patched {path}")
|
print(f"✅ Patched {path}")
|
||||||
@@ -328,101 +540,114 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
|
|||||||
print("⚠️ No Vue file found to patch, skipping frontend health check")
|
print("⚠️ No Vue file found to patch, skipping frontend health check")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
content = target_file.read_text()
|
original_content = target_file.read_text()
|
||||||
|
|
||||||
# Check if already patched
|
# Check if already patched
|
||||||
if "/api/health" in content:
|
if "/api/health" in original_content:
|
||||||
print(f"⚠️ Skipping {target_file} (already patched)")
|
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>
|
||||||
|
script_end_match = re.search(r"</script>", content)
|
||||||
|
if not script_end_match:
|
||||||
|
print(f"⚠️ Skipping {target_file} (no </script> 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 </h3>
|
||||||
|
# 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>
|
||||||
|
h3_close = content.find(" </h3>")
|
||||||
|
if h3_close == -1:
|
||||||
|
print(
|
||||||
|
f"⚠️ Skipping {target_file} (no </h3> 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 "<h1>You did it!</h1>" not in content:
|
||||||
|
print(f"⚠️ Skipping {target_file} (not a default Vue template)")
|
||||||
|
return False
|
||||||
|
# Insert before the </p> tag
|
||||||
|
template_end = content.find("</template>")
|
||||||
|
if template_end == -1:
|
||||||
|
print(f"⚠️ Skipping {target_file} (no </template> tag found)")
|
||||||
|
return False
|
||||||
|
# Find </p> before </template>
|
||||||
|
last_p = content.rfind("</p>", 0, template_end)
|
||||||
|
if last_p == -1:
|
||||||
|
print(f"⚠️ Skipping {target_file} (no </p> tag found for status insertion)")
|
||||||
|
return False
|
||||||
|
before, after = content[:last_p], content[last_p:]
|
||||||
|
content = f"{before}{STATUS_SPAN_TEMPLATE}{after}"
|
||||||
|
|
||||||
|
# Check if content actually changed
|
||||||
|
if content == original_content:
|
||||||
|
print(f"⚠️ Skipping {target_file} (no changes needed)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[DRY RUN] Would patch {target_file}")
|
print(f"[DRY RUN] Would patch {target_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Detect if TypeScript (has lang="ts" in script tag)
|
|
||||||
is_typescript = 'lang="ts"' in content
|
|
||||||
|
|
||||||
# Build the script content based on JS/TS
|
|
||||||
if is_typescript:
|
|
||||||
script_addition = """
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const backendStatus = ref<'checking' | 'connected' | 'error'>('checking')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/health')
|
|
||||||
backendStatus.value = res.ok ? 'connected' : 'error'
|
|
||||||
} catch {
|
|
||||||
backendStatus.value = 'error'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
else:
|
|
||||||
script_addition = """
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const backendStatus = ref('checking')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/health')
|
|
||||||
backendStatus.value = res.ok ? 'connected' : 'error'
|
|
||||||
} catch {
|
|
||||||
backendStatus.value = 'error'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Template addition - inline status indicator using emoji for colors
|
|
||||||
status_span = """<span style="white-space: nowrap">
|
|
||||||
— FastAPI:
|
|
||||||
<span v-if="backendStatus === 'checking'">⏳</span>
|
|
||||||
<span v-else-if="backendStatus === 'connected'">✅</span>
|
|
||||||
<span v-else>❌ not reachable</span>
|
|
||||||
</span>"""
|
|
||||||
|
|
||||||
# Insert script addition before </script>
|
|
||||||
script_end_match = re.search(r"</script>", content)
|
|
||||||
if script_end_match:
|
|
||||||
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 </h3>
|
|
||||||
# For App.vue (minimal): insert before the last </p> in template
|
|
||||||
if "HelloWorld" in str(target_file):
|
|
||||||
# Insert before closing </h3>
|
|
||||||
h3_close = content.find("</h3>")
|
|
||||||
if h3_close != -1:
|
|
||||||
content = (
|
|
||||||
f"{content[:h3_close]}\n {status_span}\n {content[h3_close:]}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Minimal App.vue - insert before the last </p> before </template>
|
|
||||||
template_end = content.find("</template>")
|
|
||||||
if template_end != -1:
|
|
||||||
# Find last </p> before </template>
|
|
||||||
last_p = content.rfind("</p>", 0, template_end)
|
|
||||||
if last_p != -1:
|
|
||||||
content = f"{content[:last_p]}\n {status_span}\n {content[last_p:]}"
|
|
||||||
|
|
||||||
target_file.write_text(content)
|
target_file.write_text(content)
|
||||||
print(f"✅ Patched {target_file}")
|
print(f"✅ Patched {target_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Track .new.py files written during setup (for merge notification)
|
||||||
|
_new_files_written: list[tuple[Path, Path]] = []
|
||||||
|
|
||||||
|
|
||||||
def write_file(
|
def write_file(
|
||||||
path: Path,
|
path: Path,
|
||||||
content: str,
|
content: str,
|
||||||
overwrite: bool = True,
|
overwrite: bool = True,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
executable: bool = False,
|
executable: bool = False,
|
||||||
|
fallback_path: Path | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Write content to a file, handling existing files and dry-run."""
|
"""Write content to a file, handling existing files and dry-run.
|
||||||
|
|
||||||
|
If fallback_path is provided and the file exists without the upgrade marker,
|
||||||
|
the content will be written to fallback_path instead of being skipped.
|
||||||
|
"""
|
||||||
exists = path.exists()
|
exists = path.exists()
|
||||||
if exists and not overwrite:
|
if exists and not overwrite:
|
||||||
print(f"⚠️ Skipping {path} (exists)")
|
print(f"ℹ️ Skipping {path} (exists)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if content is the same
|
||||||
|
if exists:
|
||||||
|
existing_content = path.read_text()
|
||||||
|
if existing_content == content:
|
||||||
|
print(f"✔️ {path} (already up to date)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If overwrite requested but file doesn't have upgrade marker
|
||||||
|
if overwrite and UPGRADE_MARKER not in existing_content:
|
||||||
|
if fallback_path is not None:
|
||||||
|
# Write to fallback path instead
|
||||||
|
return _write_fallback_file(
|
||||||
|
path, fallback_path, content, dry_run, executable
|
||||||
|
)
|
||||||
|
print(f"ℹ️ Skipping {path} (customized by user)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -439,47 +664,113 @@ def write_file(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def merge_pyproject(data: dict, additions: dict, module_name: str) -> dict:
|
def _write_fallback_file(
|
||||||
"""Merge additions into pyproject.toml data."""
|
original_path: Path,
|
||||||
result = data.copy()
|
fallback_path: Path,
|
||||||
|
content: str,
|
||||||
|
dry_run: bool,
|
||||||
|
executable: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Write content to a fallback .new.py file when original can't be overwritten."""
|
||||||
|
# Check if fallback already has same content
|
||||||
|
if fallback_path.exists():
|
||||||
|
if fallback_path.read_text() == content:
|
||||||
|
print(f"✔️ {fallback_path} (already up to date)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"[DRY RUN] Would create {fallback_path} (original customized by user)")
|
||||||
|
_new_files_written.append((fallback_path, original_path))
|
||||||
|
return True
|
||||||
|
|
||||||
|
fallback_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fallback_path.write_text(content)
|
||||||
|
if executable and sys.platform != "win32":
|
||||||
|
fallback_path.chmod(fallback_path.stat().st_mode | 0o111)
|
||||||
|
print(f"✅ Created {fallback_path} (original customized by user)")
|
||||||
|
_new_files_written.append((fallback_path, original_path))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def merge_pyproject(
|
||||||
|
data: tomlkit.TOMLDocument, additions: dict, module_name: str
|
||||||
|
) -> tomlkit.TOMLDocument:
|
||||||
|
"""Merge additions into pyproject.toml data, preserving comments and existing values."""
|
||||||
# Ensure hatchling build system is configured
|
# Ensure hatchling build system is configured
|
||||||
if "build-system" not in result:
|
if "build-system" not in data:
|
||||||
result["build-system"] = {}
|
data["build-system"] = tomlkit.table()
|
||||||
result["build-system"]["requires"] = ["hatchling"]
|
|
||||||
result["build-system"]["build-backend"] = "hatchling.build"
|
|
||||||
|
|
||||||
# Add dependencies
|
# Add hatchling to requires if not present, preserving existing requires
|
||||||
if "project" not in result:
|
if "requires" not in data["build-system"]:
|
||||||
result["project"] = {}
|
data["build-system"]["requires"] = ["hatchling"]
|
||||||
if "dependencies" not in result["project"]:
|
else:
|
||||||
result["project"]["dependencies"] = []
|
requires = list(data["build-system"]["requires"])
|
||||||
|
if not any(r.startswith("hatchling") for r in requires):
|
||||||
|
requires.insert(0, "hatchling")
|
||||||
|
data["build-system"]["requires"] = requires
|
||||||
|
|
||||||
|
if "build-backend" not in data["build-system"]:
|
||||||
|
data["build-system"]["build-backend"] = "hatchling.build"
|
||||||
|
|
||||||
|
# Ensure project table exists
|
||||||
|
if "project" not in data:
|
||||||
|
data["project"] = tomlkit.table()
|
||||||
|
|
||||||
|
# Ensure Python version is at least 3.11 (required by fastapi-vue)
|
||||||
|
if "requires-python" in data["project"]:
|
||||||
|
req = data["project"]["requires-python"]
|
||||||
|
# Parse minimum version from strings like ">=3.10" or ">=3.9,<4"
|
||||||
|
import re
|
||||||
|
match = re.search(r">=\s*(\d+)\.(\d+)", req)
|
||||||
|
if match:
|
||||||
|
major, minor = int(match.group(1)), int(match.group(2))
|
||||||
|
if major < 3 or (major == 3 and minor < 11):
|
||||||
|
data["project"]["requires-python"] = ">=3.11"
|
||||||
|
else:
|
||||||
|
data["project"]["requires-python"] = ">=3.11"
|
||||||
|
|
||||||
# Add hatch build config
|
# Add hatch build config
|
||||||
if "tool" not in result:
|
if "tool" not in data:
|
||||||
result["tool"] = {}
|
data["tool"] = tomlkit.table()
|
||||||
if "hatch" not in result["tool"]:
|
if "hatch" not in data["tool"]:
|
||||||
result["tool"]["hatch"] = {}
|
data["tool"]["hatch"] = tomlkit.table()
|
||||||
if "build" not in result["tool"]["hatch"]:
|
if "build" not in data["tool"]["hatch"]:
|
||||||
result["tool"]["hatch"]["build"] = {}
|
data["tool"]["hatch"]["build"] = tomlkit.table()
|
||||||
|
|
||||||
hatch_build = additions["tool"]["hatch"]["build"]
|
hatch_build = data["tool"]["hatch"]["build"]
|
||||||
result["tool"]["hatch"]["build"]["packages"] = [
|
hatch_additions = additions["tool"]["hatch"]["build"]
|
||||||
p.replace("MODULE_NAME", module_name) for p in hatch_build["packages"]
|
|
||||||
]
|
|
||||||
result["tool"]["hatch"]["build"]["artifacts"] = [
|
|
||||||
a.replace("MODULE_NAME", module_name) for a in hatch_build["artifacts"]
|
|
||||||
]
|
|
||||||
result["tool"]["hatch"]["build"]["only-packages"] = hatch_build["only-packages"]
|
|
||||||
|
|
||||||
if "targets" not in result["tool"]["hatch"]["build"]:
|
# Set packages if not already set
|
||||||
result["tool"]["hatch"]["build"]["targets"] = {}
|
if "packages" not in hatch_build:
|
||||||
|
hatch_build["packages"] = [
|
||||||
result["tool"]["hatch"]["build"]["targets"]["sdist"] = hatch_build["targets"][
|
p.replace("MODULE_NAME", module_name) for p in hatch_additions["packages"]
|
||||||
"sdist"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return result
|
# Set artifacts if not already set
|
||||||
|
if "artifacts" not in hatch_build:
|
||||||
|
hatch_build["artifacts"] = [
|
||||||
|
a.replace("MODULE_NAME", module_name) for a in hatch_additions["artifacts"]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Set only-packages if not already set
|
||||||
|
if "only-packages" not in hatch_build:
|
||||||
|
hatch_build["only-packages"] = hatch_additions["only-packages"]
|
||||||
|
|
||||||
|
# Add sdist target with custom hook
|
||||||
|
if "targets" not in hatch_build:
|
||||||
|
hatch_build["targets"] = tomlkit.table()
|
||||||
|
if "sdist" not in hatch_build["targets"]:
|
||||||
|
hatch_build["targets"]["sdist"] = tomlkit.table()
|
||||||
|
if "hooks" not in hatch_build["targets"]["sdist"]:
|
||||||
|
hatch_build["targets"]["sdist"]["hooks"] = tomlkit.table()
|
||||||
|
if "custom" not in hatch_build["targets"]["sdist"]["hooks"]:
|
||||||
|
hatch_build["targets"]["sdist"]["hooks"]["custom"] = tomlkit.table()
|
||||||
|
if "path" not in hatch_build["targets"]["sdist"]["hooks"]["custom"]:
|
||||||
|
hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] = hatch_additions[
|
||||||
|
"targets"
|
||||||
|
]["sdist"]["hooks"]["custom"]["path"]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -625,12 +916,12 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
if dry_run:
|
if dry_run:
|
||||||
print("\n🏃 DRY RUN MODE - no changes will be made\n")
|
print("\n🏃 DRY RUN MODE - no changes will be made\n")
|
||||||
|
|
||||||
# Step 1: Ensure Python project exists
|
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
||||||
if not ensure_python_project(project_dir, dry_run):
|
if not ensure_frontend(project_dir, dry_run):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Step 2: Ensure frontend exists
|
# Step 2: Ensure Python project exists
|
||||||
if not ensure_frontend(project_dir, dry_run):
|
if not ensure_python_project(project_dir, dry_run):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Detect module name
|
# Detect module name
|
||||||
@@ -656,7 +947,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
|
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
|
||||||
|
|
||||||
# Find existing FastAPI app
|
# Find existing FastAPI app
|
||||||
app_info = find_fastapi_app(module_dir) if module_dir.exists() else None
|
app_info = find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None
|
||||||
|
|
||||||
if app_info:
|
if app_info:
|
||||||
app_file, app_var = app_info
|
app_file, app_var = app_info
|
||||||
@@ -677,25 +968,38 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
module_dir.mkdir(parents=True)
|
module_dir.mkdir(parents=True)
|
||||||
|
|
||||||
# === Install scripts (always update our own scripts) ===
|
# === Install scripts (always update our own scripts) ===
|
||||||
script_files = [
|
# util.py and build-frontend.py are internal and always overwritten
|
||||||
|
# devserver.py can be customized, so use fallback if needed
|
||||||
|
internal_script_files = [
|
||||||
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
|
(fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"),
|
||||||
(
|
(
|
||||||
fastapi_vue_scripts / "build-frontend.py",
|
fastapi_vue_scripts / "build-frontend.py",
|
||||||
"scripts/fastapi-vue/build-frontend.py",
|
"scripts/fastapi-vue/build-frontend.py",
|
||||||
),
|
),
|
||||||
(scripts_dir / "devserver.py", "scripts/devserver.py"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for dest_path, template_path in script_files:
|
for dest_path, template_path in internal_script_files:
|
||||||
template = load_template(template_path)
|
template = load_template(template_path)
|
||||||
content = render_template(template, **tpl_vars)
|
content = render_template(template, **tpl_vars)
|
||||||
is_executable = dest_path.name == "devserver.py"
|
|
||||||
write_file(
|
write_file(
|
||||||
dest_path,
|
dest_path,
|
||||||
content,
|
content,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
executable=is_executable,
|
)
|
||||||
|
|
||||||
|
# devserver.py - use fallback if customized by user
|
||||||
|
devserver_path = scripts_dir / "devserver.py"
|
||||||
|
devserver_fallback = scripts_dir / "devserver.new.py"
|
||||||
|
template = load_template("scripts/devserver.py")
|
||||||
|
content = render_template(template, **tpl_vars)
|
||||||
|
write_file(
|
||||||
|
devserver_path,
|
||||||
|
content,
|
||||||
|
overwrite=True,
|
||||||
|
dry_run=dry_run,
|
||||||
|
executable=True,
|
||||||
|
fallback_path=devserver_fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Handle app module ===
|
# === Handle app module ===
|
||||||
@@ -718,11 +1022,27 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
|
write_file(app_file_path, content, overwrite=False, dry_run=dry_run)
|
||||||
|
|
||||||
# === Handle __main__.py ===
|
# === Handle __main__.py ===
|
||||||
|
# Skip if project already has a CLI entrypoint in pyproject.toml
|
||||||
|
existing_cli = _find_existing_cli_entrypoint(project_dir, module_name)
|
||||||
|
if existing_cli:
|
||||||
|
print(f"ℹ️ Using existing CLI entrypoint: {existing_cli}")
|
||||||
|
else:
|
||||||
main_file = module_dir / "__main__.py"
|
main_file = module_dir / "__main__.py"
|
||||||
if not main_file.exists():
|
main_fallback = module_dir / "__main__.new.py"
|
||||||
template = load_template("backend/__main__.py")
|
template = load_template("backend/__main__.py")
|
||||||
content = render_template(template, **tpl_vars)
|
main_content = render_template(template, **tpl_vars)
|
||||||
write_file(main_file, content, overwrite=False, dry_run=dry_run)
|
|
||||||
|
# Use write_file with fallback - it handles all cases:
|
||||||
|
# - File doesn't exist: create it
|
||||||
|
# - File exists with marker: update it
|
||||||
|
# - File exists without marker: write to fallback
|
||||||
|
write_file(
|
||||||
|
main_file,
|
||||||
|
main_content,
|
||||||
|
overwrite=True,
|
||||||
|
dry_run=dry_run,
|
||||||
|
fallback_path=main_fallback,
|
||||||
|
)
|
||||||
|
|
||||||
# === Update vite.config.js/ts ===
|
# === Update vite.config.js/ts ===
|
||||||
frontend_dir = project_dir / "frontend"
|
frontend_dir = project_dir / "frontend"
|
||||||
@@ -751,39 +1071,43 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
# === Update pyproject.toml ===
|
# === Update pyproject.toml ===
|
||||||
pyproject_path = project_dir / "pyproject.toml"
|
pyproject_path = project_dir / "pyproject.toml"
|
||||||
if pyproject_path.exists():
|
if pyproject_path.exists():
|
||||||
with open(pyproject_path, "rb") as f:
|
old_content = pyproject_path.read_text()
|
||||||
data = tomllib.load(f)
|
data = tomlkit.parse(old_content)
|
||||||
|
|
||||||
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
|
updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name)
|
||||||
|
|
||||||
# Add script entry pointing to the app we found/created
|
# Only add script entry if project doesn't already have scripts
|
||||||
if "scripts" not in updated["project"]:
|
if "scripts" not in updated["project"] or not updated["project"]["scripts"]:
|
||||||
updated["project"]["scripts"] = {}
|
updated["project"]["scripts"] = tomlkit.table()
|
||||||
script_name = module_name.replace("_", "-")
|
script_name = module_name.replace("_", "-")
|
||||||
updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main"
|
updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main"
|
||||||
|
|
||||||
if dry_run:
|
# Check if content actually changed
|
||||||
|
new_content = tomlkit.dumps(updated)
|
||||||
|
|
||||||
|
if new_content == old_content:
|
||||||
|
print(f"✔️ {pyproject_path} (already up to date)")
|
||||||
|
elif dry_run:
|
||||||
print(f"[DRY RUN] Would update {pyproject_path}")
|
print(f"[DRY RUN] Would update {pyproject_path}")
|
||||||
else:
|
else:
|
||||||
with open(pyproject_path, "wb") as f:
|
pyproject_path.write_text(new_content)
|
||||||
tomli_w.dump(updated, f)
|
|
||||||
print(f"✅ Updated {pyproject_path}")
|
print(f"✅ Updated {pyproject_path}")
|
||||||
|
|
||||||
# === Add dependencies using uv ===
|
# === Add dependencies using uv ===
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print("[DRY RUN] Would run: uv add 'fastapi[standard]' fastapi-vue")
|
print("[DRY RUN] Would run: uv add -U 'fastapi[standard]' fastapi-vue")
|
||||||
print("[DRY RUN] Would run: uv add --group dev httpx")
|
print("[DRY RUN] Would run: uv add -U --group dev httpx")
|
||||||
else:
|
else:
|
||||||
print("📦 Adding dependencies...")
|
print("📦 Adding dependencies...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["uv", "add", "fastapi[standard]", "fastapi-vue"],
|
["uv", "add", "-U", "fastapi[standard]", "fastapi-vue"],
|
||||||
cwd=project_dir,
|
cwd=project_dir,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("⚠️ Failed to add main dependencies")
|
print("⚠️ Failed to add main dependencies")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["uv", "add", "--group", "dev", "httpx"],
|
["uv", "add", "-U", "--group", "dev", "httpx"],
|
||||||
cwd=project_dir,
|
cwd=project_dir,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
@@ -792,17 +1116,17 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# === Update .gitignore ===
|
# === Update .gitignore ===
|
||||||
gitignore_path = project_dir / ".gitignore"
|
gitignore_path = project_dir / ".gitignore"
|
||||||
gitignore_entry = f"{module_name}/frontend-build/"
|
gitignore_entry = f"/{module_name}/frontend-build"
|
||||||
if gitignore_path.exists():
|
if gitignore_path.exists():
|
||||||
gitignore_content = gitignore_path.read_text()
|
gitignore_content = gitignore_path.read_bytes()
|
||||||
if gitignore_entry not in gitignore_content:
|
if b"frontend-build" in gitignore_content:
|
||||||
if dry_run:
|
print("✔️ .gitignore (frontend-build already ignored)")
|
||||||
|
elif dry_run:
|
||||||
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
|
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
|
||||||
else:
|
else:
|
||||||
with open(gitignore_path, "a") as f:
|
nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n"
|
||||||
if not gitignore_content.endswith("\n"):
|
suffix = b"" if gitignore_content.endswith(nl) else nl
|
||||||
f.write("\n")
|
gitignore_path.write_bytes(gitignore_content + suffix + gitignore_entry.encode() + nl)
|
||||||
f.write(f"{gitignore_entry}\n")
|
|
||||||
print(f"✅ Added {gitignore_entry} to .gitignore")
|
print(f"✅ Added {gitignore_entry} to .gitignore")
|
||||||
elif dry_run:
|
elif dry_run:
|
||||||
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
|
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
|
||||||
@@ -819,18 +1143,23 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
cd_cmd = "" if project_dir == Path.cwd() else f"cd {project_dir}\n "
|
cd_cmd = "" if project_dir == Path.cwd() else f"cd {project_dir}\n "
|
||||||
script_name = module_name.replace("_", "-")
|
script_name = module_name.replace("_", "-")
|
||||||
|
|
||||||
print(f"""
|
message = SETUP_COMPLETE_MESSAGE.replace("CD_CMD", cd_cmd).replace(
|
||||||
Next steps:
|
"SCRIPT_NAME", script_name
|
||||||
|
)
|
||||||
|
print(message)
|
||||||
|
|
||||||
1. Build for production:
|
# Show merge note if any .new.py files were written
|
||||||
{cd_cmd}uv build
|
if _new_files_written:
|
||||||
|
print()
|
||||||
2. Start development server:
|
print(
|
||||||
{cd_cmd}uv run scripts/devserver.py
|
"⚠️ Note: Some files could not be auto-upgraded because you customized them."
|
||||||
|
)
|
||||||
3. Run production server:
|
print(" Please manually merge the following files:")
|
||||||
{cd_cmd}uv run {script_name}
|
for new_file, original_file in _new_files_written:
|
||||||
""")
|
print(f" • {new_file.name} → {original_file.name}")
|
||||||
|
print()
|
||||||
|
# Clear the list for potential subsequent runs
|
||||||
|
_new_files_written.clear()
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -875,7 +1204,7 @@ Examples:
|
|||||||
"project_dir",
|
"project_dir",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default=None,
|
default=None,
|
||||||
help="Project directory (default: current directory)",
|
help="Project directory (use . for current directory)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--module-name", help="Python module name (auto-detected)")
|
parser.add_argument("--module-name", help="Python module name (auto-detected)")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -884,26 +1213,9 @@ Examples:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Handle default project directory with safety check
|
|
||||||
if args.project_dir is None:
|
if args.project_dir is None:
|
||||||
cwd = Path.cwd()
|
parser.print_help()
|
||||||
if not is_uninitialized_folder(cwd) and not is_already_patched(cwd):
|
return 0
|
||||||
print(
|
|
||||||
"⚠️ Current directory contains an existing project that hasn't been patched yet."
|
|
||||||
)
|
|
||||||
print()
|
|
||||||
print(
|
|
||||||
" If you want to set up FastAPI+Vue integration in the current directory, run:"
|
|
||||||
)
|
|
||||||
print(" fastapi-vue-setup .")
|
|
||||||
print()
|
|
||||||
print(" Or specify a new project directory:")
|
|
||||||
print(" fastapi-vue-setup my-new-project")
|
|
||||||
print()
|
|
||||||
print(" Python project will be at root, while Vue lives in frontend/.")
|
|
||||||
print()
|
|
||||||
return 1
|
|
||||||
args.project_dir = "."
|
|
||||||
|
|
||||||
return cmd_setup(args)
|
return cmd_setup(args)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tomli-w>=1.0.0",
|
"tomlkit>=0.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -1,30 +1,54 @@
|
|||||||
import sys
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
|
from .APP_MODULE import APP_VAR
|
||||||
|
|
||||||
|
DEFAULT_PORT = 5080
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(endpoints: list[dict], *, proxy="", devmode=False):
|
||||||
|
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
|
||||||
|
if proxy:
|
||||||
|
conf["proxy_headers"] = True
|
||||||
|
conf["forwarded_allow_ips"] = proxy
|
||||||
|
if devmode:
|
||||||
|
conf["reload"] = True
|
||||||
|
conf["reload_dirs"] = ["MODULE_NAME"]
|
||||||
|
APP_VAR.debug = True
|
||||||
|
|
||||||
|
if len(endpoints) > 1:
|
||||||
|
# Run separate servers for multiple endpoints
|
||||||
|
async def serve_all():
|
||||||
|
async with asyncio.TaskGroup() as tg:
|
||||||
|
for ep in endpoints:
|
||||||
|
tg.create_task(Server(Config(**conf, **ep)).serve())
|
||||||
|
|
||||||
|
asyncio.run(serve_all())
|
||||||
|
else:
|
||||||
|
uvicorn.run(**conf, **endpoints[0])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run the FastAPI application using uvicorn."""
|
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
||||||
|
parser.add_argument(
|
||||||
if len(sys.argv) > 1:
|
"endpoint",
|
||||||
endpoint = sys.argv[1]
|
nargs="?",
|
||||||
if ":" in endpoint:
|
help=(
|
||||||
host, port = endpoint.rsplit(":", 1)
|
f"Endpoint (default: localhost:{DEFAULT_PORT}). "
|
||||||
host = host or "localhost"
|
"Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock"
|
||||||
port = int(port)
|
),
|
||||||
else:
|
|
||||||
host = "localhost"
|
|
||||||
port = int(endpoint)
|
|
||||||
else:
|
|
||||||
host = "localhost"
|
|
||||||
port = 5080
|
|
||||||
|
|
||||||
uvicorn.run(
|
|
||||||
"MODULE_NAME.app:app",
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
log_level="info",
|
|
||||||
)
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||||
|
devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL"))
|
||||||
|
endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT)
|
||||||
|
run_server(endpoints, proxy=proxy, devmode=devmode)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -16,17 +17,15 @@ Environment:
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import ipaddress
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
|
||||||
DEFAULT_VITE_PORT = 5173
|
DEFAULT_VITE_PORT = 5173
|
||||||
DEFAULT_BACKEND_PORT = 5180
|
DEFAULT_BACKEND_PORT = 5180
|
||||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||||
@@ -45,45 +44,8 @@ BUN_BUG = """\
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def parse_endpoint(
|
|
||||||
value: str | None, default_port: int = DEFAULT_VITE_PORT
|
|
||||||
) -> tuple[str | None, int, bool]:
|
|
||||||
"""Parse an endpoint for Vite or backend.
|
|
||||||
|
|
||||||
Returns (host, port, all_ifaces).
|
|
||||||
"""
|
|
||||||
if not value:
|
|
||||||
return DEFAULT_HOST, default_port, False
|
|
||||||
|
|
||||||
# Port only (numeric) -> localhost:port
|
|
||||||
if value.isdigit():
|
|
||||||
return DEFAULT_HOST, int(value), False
|
|
||||||
|
|
||||||
# Leading colon :port -> bind all interfaces
|
|
||||||
if value.startswith(":") and value != ":":
|
|
||||||
port_part = value[1:]
|
|
||||||
if not port_part.isdigit():
|
|
||||||
raise SystemExit(f"Invalid port in '{value}'")
|
|
||||||
return None, int(port_part), True
|
|
||||||
|
|
||||||
# Unbracketed IPv6 (cannot safely contain a port)
|
|
||||||
if value.count(":") > 1 and not value.startswith("["):
|
|
||||||
try:
|
|
||||||
ipaddress.IPv6Address(value)
|
|
||||||
except ValueError as e:
|
|
||||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
|
||||||
return value, default_port, False
|
|
||||||
|
|
||||||
# Use urllib.parse for everything else
|
|
||||||
parsed = urlparse(f"//{value}")
|
|
||||||
host = parsed.hostname or DEFAULT_HOST
|
|
||||||
port = parsed.port or default_port
|
|
||||||
|
|
||||||
return host, port, False
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_frontend_tools(
|
def resolve_frontend_tools(
|
||||||
vite_host: str | None, vite_port: int, all_ifaces: bool
|
vite_port: int, all_ifaces: bool
|
||||||
) -> tuple[list[str], list[str], str]:
|
) -> tuple[list[str], list[str], str]:
|
||||||
"""Resolve frontend install and dev commands.
|
"""Resolve frontend install and dev commands.
|
||||||
|
|
||||||
@@ -114,12 +76,15 @@ def resolve_frontend_tools(
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_cmd = [tool, *install_args[name]]
|
install_cmd = [tool, *install_args[name]]
|
||||||
dev_cmd = [tool, *dev_args[name], "--port", str(vite_port)]
|
dev_cmd = [
|
||||||
|
tool,
|
||||||
|
*dev_args[name],
|
||||||
|
"--clearScreen=false",
|
||||||
|
f"--port={vite_port}",
|
||||||
|
]
|
||||||
|
|
||||||
if all_ifaces:
|
if all_ifaces:
|
||||||
dev_cmd.append("--host")
|
dev_cmd.append("--host")
|
||||||
elif vite_host:
|
|
||||||
dev_cmd.extend(["--host", vite_host])
|
|
||||||
|
|
||||||
if name == "bun":
|
if name == "bun":
|
||||||
stderr.write(BUN_BUG)
|
stderr.write(BUN_BUG)
|
||||||
@@ -127,13 +92,15 @@ def resolve_frontend_tools(
|
|||||||
return install_cmd, dev_cmd, name
|
return install_cmd, dev_cmd, name
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_backend(backend_host: str, backend_port: int):
|
async def wait_for_backend(host: str, port: int):
|
||||||
"""Wait for the backend to be ready by polling the health endpoint."""
|
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||||
max_attempts = 50
|
max_attempts = 50
|
||||||
|
url = f"http://{host}:{port}"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
await client.get(f"http://{backend_host}:{backend_port}", timeout=1.0)
|
await client.get(url, timeout=1.0)
|
||||||
stderr.write("✓ Backend ready!\n")
|
stderr.write("✓ Backend ready!\n")
|
||||||
return True
|
return True
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
@@ -163,23 +130,29 @@ async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> Non
|
|||||||
|
|
||||||
|
|
||||||
async def run_devserver(
|
async def run_devserver(
|
||||||
vite_host: str | None,
|
|
||||||
vite_port: int,
|
vite_port: int,
|
||||||
all_ifaces: bool,
|
all_ifaces: bool,
|
||||||
backend_host: str,
|
backend_host: str,
|
||||||
backend_port: int,
|
backend_port: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run the development server with install, backend, and frontend."""
|
"""Run the development server with install, backend, and frontend."""
|
||||||
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
|
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
|
||||||
vite_host, vite_port, all_ifaces
|
|
||||||
)
|
|
||||||
|
|
||||||
# Tell the backend where the Vite dev server is
|
# Tell the backend where the Vite dev server is
|
||||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
|
os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}"
|
||||||
f"http://{vite_host or 'localhost'}:{vite_port}"
|
|
||||||
)
|
|
||||||
# Tell Vite where the backend is (for proxying /api requests)
|
# Tell Vite where the backend is (for proxying /api requests)
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
||||||
|
|
||||||
|
backend_cmd = [
|
||||||
|
"uvicorn",
|
||||||
|
"MODULE_NAME.app:app",
|
||||||
|
"--host",
|
||||||
|
backend_host,
|
||||||
|
"--port",
|
||||||
|
str(backend_port),
|
||||||
|
"--reload",
|
||||||
|
]
|
||||||
|
|
||||||
cwd = str(Path(__file__).parent.parent)
|
cwd = str(Path(__file__).parent.parent)
|
||||||
frontend_cwd = str(FRONTEND_PATH)
|
frontend_cwd = str(FRONTEND_PATH)
|
||||||
|
|
||||||
@@ -197,15 +170,6 @@ async def run_devserver(
|
|||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# Start backend (concurrent with install)
|
# Start backend (concurrent with install)
|
||||||
backend_cmd = [
|
|
||||||
"uvicorn",
|
|
||||||
"MODULE_NAME.app:app",
|
|
||||||
"--host",
|
|
||||||
backend_host,
|
|
||||||
"--port",
|
|
||||||
str(backend_port),
|
|
||||||
"--reload",
|
|
||||||
]
|
|
||||||
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||||
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
||||||
|
|
||||||
@@ -287,15 +251,26 @@ def main():
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
vite_host, vite_port, all_ifaces = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
# parse_endpoint returns list of dicts with host/port or uds keys
|
||||||
backend_host, backend_port, _ = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
# Multiple entries means bind all interfaces (IPv4 + IPv6)
|
||||||
# Backend host defaults to localhost (never None)
|
vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
||||||
backend_host = backend_host or DEFAULT_HOST
|
backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
||||||
|
|
||||||
|
# Vite doesn't support unix sockets
|
||||||
|
if "uds" in vite_endpoints[0]:
|
||||||
|
stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
if "uds" in backend_endpoints[0]:
|
||||||
|
stderr.write("┃ ⚠️ Unix sockets not supported for backend\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
vite_port = vite_endpoints[0]["port"]
|
||||||
|
all_ifaces = len(vite_endpoints) > 1
|
||||||
|
backend_host = backend_endpoints[0]["host"]
|
||||||
|
backend_port = backend_endpoints[0]["port"]
|
||||||
|
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(
|
asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
|
||||||
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -51,10 +51,9 @@ def find_build_tool():
|
|||||||
"npm": ("install",),
|
"npm": ("install",),
|
||||||
"bun": ("--bun", "install"),
|
"bun": ("--bun", "install"),
|
||||||
}
|
}
|
||||||
# Use build-only for deno to avoid npm-run-all2 issues with run-p
|
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||||
# (run-p tries to spawn npm which doesn't exist in deno)
|
|
||||||
build = {
|
build = {
|
||||||
"deno": ("task", "build-only"),
|
"deno": ("run", "-A", "npm:vite", "build"),
|
||||||
"npm": ("run", "build"),
|
"npm": ("run", "build"),
|
||||||
"bun": ("--bun", "run", "build"),
|
"bun": ("--bun", "run", "build"),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user