framework-m-studio 0.2.3__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- framework_m_studio/__init__.py +6 -1
- framework_m_studio/app.py +56 -11
- framework_m_studio/checklist_parser.py +421 -0
- framework_m_studio/cli/__init__.py +752 -0
- framework_m_studio/cli/build.py +421 -0
- framework_m_studio/cli/dev.py +214 -0
- framework_m_studio/cli/new.py +754 -0
- framework_m_studio/cli/quality.py +157 -0
- framework_m_studio/cli/studio.py +159 -0
- framework_m_studio/cli/utility.py +50 -0
- framework_m_studio/codegen/generator.py +6 -2
- framework_m_studio/codegen/parser.py +101 -4
- framework_m_studio/codegen/templates/doctype.py.jinja2 +19 -10
- framework_m_studio/codegen/test_generator.py +6 -2
- framework_m_studio/discovery.py +15 -5
- framework_m_studio/docs_generator.py +298 -2
- framework_m_studio/protocol_scanner.py +435 -0
- framework_m_studio/routes.py +39 -11
- {framework_m_studio-0.2.3.dist-info → framework_m_studio-0.3.0.dist-info}/METADATA +7 -2
- framework_m_studio-0.3.0.dist-info/RECORD +32 -0
- framework_m_studio-0.3.0.dist-info/entry_points.txt +18 -0
- framework_m_studio/cli.py +0 -247
- framework_m_studio/static/assets/index-BJ5Noua8.js +0 -171
- framework_m_studio/static/assets/index-CnPUX2YK.css +0 -1
- framework_m_studio/static/favicon.ico +0 -0
- framework_m_studio/static/index.html +0 -40
- framework_m_studio-0.2.3.dist-info/RECORD +0 -28
- framework_m_studio-0.2.3.dist-info/entry_points.txt +0 -4
- {framework_m_studio-0.2.3.dist-info → framework_m_studio-0.3.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Build CLI Commands.
|
|
2
|
+
|
|
3
|
+
This module provides CLI commands for building Framework M applications:
|
|
4
|
+
- m build: Build frontend assets (placeholder for Phase 09)
|
|
5
|
+
- m build:docker: Build Docker image
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
m build # Build frontend
|
|
9
|
+
m build:docker # Build Docker image
|
|
10
|
+
m build:docker --tag app:v1 # Custom tag
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated
|
|
20
|
+
|
|
21
|
+
import cyclopts
|
|
22
|
+
from framework_m_core.config import load_config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_default_image_name() -> str:
|
|
26
|
+
"""Get default Docker image name from config or project name.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Default image name
|
|
30
|
+
"""
|
|
31
|
+
config = load_config()
|
|
32
|
+
framework_config = config.get("framework", {})
|
|
33
|
+
|
|
34
|
+
name = framework_config.get("name", "framework-m-app")
|
|
35
|
+
version = framework_config.get("version", "latest")
|
|
36
|
+
|
|
37
|
+
return f"{name}:{version}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_docker_cmd(
|
|
41
|
+
tag: str,
|
|
42
|
+
dockerfile: str = "Dockerfile",
|
|
43
|
+
context: str = ".",
|
|
44
|
+
no_cache: bool = False,
|
|
45
|
+
build_args: list[str] | None = None,
|
|
46
|
+
) -> list[str]:
|
|
47
|
+
"""Build the docker build command.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
tag: Image tag
|
|
51
|
+
dockerfile: Path to Dockerfile
|
|
52
|
+
context: Build context path
|
|
53
|
+
no_cache: Disable cache
|
|
54
|
+
build_args: Build arguments
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Command list
|
|
58
|
+
"""
|
|
59
|
+
cmd = ["docker", "build", "-t", tag, "-f", dockerfile]
|
|
60
|
+
|
|
61
|
+
if no_cache:
|
|
62
|
+
cmd.append("--no-cache")
|
|
63
|
+
|
|
64
|
+
if build_args:
|
|
65
|
+
for arg in build_args:
|
|
66
|
+
cmd.extend(["--build-arg", arg])
|
|
67
|
+
|
|
68
|
+
cmd.append(context)
|
|
69
|
+
return cmd
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# =============================================================================
|
|
73
|
+
# CLI Commands
|
|
74
|
+
# =============================================================================
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_command(
|
|
78
|
+
mode: Annotated[
|
|
79
|
+
str,
|
|
80
|
+
cyclopts.Parameter(help="Build mode: development or production"),
|
|
81
|
+
] = "production",
|
|
82
|
+
output: Annotated[
|
|
83
|
+
Path,
|
|
84
|
+
cyclopts.Parameter(name="--output", help="Output directory"),
|
|
85
|
+
] = Path("dist"),
|
|
86
|
+
frontend_mode: Annotated[
|
|
87
|
+
str | None,
|
|
88
|
+
cyclopts.Parameter(
|
|
89
|
+
name="--frontend-mode", help="Override frontend mode: indie|plugin|eject"
|
|
90
|
+
),
|
|
91
|
+
] = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Build frontend assets.
|
|
94
|
+
|
|
95
|
+
Built-in frontend modes (from framework_config.toml):
|
|
96
|
+
- indie: Build default Desk (no custom code)
|
|
97
|
+
- plugin: Shadow Build with app plugins
|
|
98
|
+
- eject: Skip (user manages their own build)
|
|
99
|
+
|
|
100
|
+
Examples:
|
|
101
|
+
m build # Production build
|
|
102
|
+
m build --mode development
|
|
103
|
+
m build --frontend-mode plugin # Override config
|
|
104
|
+
"""
|
|
105
|
+
print("Frontend Build")
|
|
106
|
+
print("=" * 40)
|
|
107
|
+
print()
|
|
108
|
+
|
|
109
|
+
# Load config to detect frontend mode
|
|
110
|
+
config = load_config()
|
|
111
|
+
frontend_config = config.get("frontend", {})
|
|
112
|
+
detected_mode = frontend_mode or frontend_config.get("mode", "indie")
|
|
113
|
+
|
|
114
|
+
print(f" Build Mode: {mode}")
|
|
115
|
+
print(f" Frontend Mode: {detected_mode}")
|
|
116
|
+
print(f" Output: {output}")
|
|
117
|
+
print()
|
|
118
|
+
|
|
119
|
+
# Handle different frontend modes
|
|
120
|
+
if detected_mode == "eject":
|
|
121
|
+
_build_eject_mode()
|
|
122
|
+
elif detected_mode == "plugin":
|
|
123
|
+
_build_plugin_mode(mode, output)
|
|
124
|
+
else: # indie (default)
|
|
125
|
+
_build_indie_mode(mode, output)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_eject_mode() -> None:
|
|
129
|
+
"""Handle eject mode - user manages their own build."""
|
|
130
|
+
print("Frontend mode: eject")
|
|
131
|
+
print()
|
|
132
|
+
print("Eject mode detected. Skipping frontend build.")
|
|
133
|
+
print("User is responsible for their own frontend build process.")
|
|
134
|
+
print()
|
|
135
|
+
print("If you want Framework M to build the frontend, change mode in")
|
|
136
|
+
print("framework_config.toml:")
|
|
137
|
+
print()
|
|
138
|
+
print(" [frontend]")
|
|
139
|
+
print(' mode = "indie" # or "plugin"')
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_indie_mode(mode: str, output: Path) -> None:
|
|
143
|
+
"""Build indie mode - default Desk only."""
|
|
144
|
+
print("Frontend mode: indie (default Desk)")
|
|
145
|
+
print()
|
|
146
|
+
|
|
147
|
+
# Check for framework's frontend directory
|
|
148
|
+
framework_frontend = Path(__file__).parent.parent.parent.parent / "frontend"
|
|
149
|
+
|
|
150
|
+
# Also check relative paths
|
|
151
|
+
frontend_dirs = [
|
|
152
|
+
framework_frontend,
|
|
153
|
+
Path.cwd() / "frontend",
|
|
154
|
+
Path.cwd() / "static" / "frontend",
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
frontend_dir = None
|
|
158
|
+
for d in frontend_dirs:
|
|
159
|
+
if (d / "package.json").exists():
|
|
160
|
+
frontend_dir = d
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
if frontend_dir is None:
|
|
164
|
+
print("Warning: No frontend directory found.")
|
|
165
|
+
print()
|
|
166
|
+
print("For indie mode, ensure frontend/ exists with package.json.")
|
|
167
|
+
print("See Phase 09A: Frontend for setup instructions.")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
print(f"Building from: {frontend_dir}")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
# Prefer pnpm, fallback to npm
|
|
174
|
+
pkg_manager = "pnpm" if _has_command("pnpm") else "npm"
|
|
175
|
+
|
|
176
|
+
# Install dependencies if needed
|
|
177
|
+
if not (frontend_dir / "node_modules").exists():
|
|
178
|
+
print(f"Installing dependencies with {pkg_manager}...")
|
|
179
|
+
subprocess.run(
|
|
180
|
+
[pkg_manager, "install"],
|
|
181
|
+
cwd=frontend_dir,
|
|
182
|
+
check=True,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Build
|
|
186
|
+
build_cmd = [pkg_manager, "run", "build"]
|
|
187
|
+
if mode == "development":
|
|
188
|
+
build_cmd = [pkg_manager, "run", "dev"] # or build:dev if exists
|
|
189
|
+
|
|
190
|
+
print(f"Running: {' '.join(build_cmd)}")
|
|
191
|
+
subprocess.run(build_cmd, cwd=frontend_dir, check=True)
|
|
192
|
+
|
|
193
|
+
# Copy to output
|
|
194
|
+
frontend_dist = frontend_dir / "dist"
|
|
195
|
+
if frontend_dist.exists() and frontend_dist != output:
|
|
196
|
+
import shutil
|
|
197
|
+
|
|
198
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
shutil.copytree(frontend_dist, output, dirs_exist_ok=True)
|
|
200
|
+
print(f"Copied build to: {output}")
|
|
201
|
+
|
|
202
|
+
print()
|
|
203
|
+
print("✓ Frontend build complete (indie mode)")
|
|
204
|
+
|
|
205
|
+
except FileNotFoundError as e:
|
|
206
|
+
print(
|
|
207
|
+
"Error: Package manager not found. Install Node.js/pnpm.", file=sys.stderr
|
|
208
|
+
)
|
|
209
|
+
raise SystemExit(1) from e
|
|
210
|
+
except subprocess.CalledProcessError as e:
|
|
211
|
+
print(f"Error: Build failed with exit code {e.returncode}", file=sys.stderr)
|
|
212
|
+
raise SystemExit(e.returncode) from e
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _build_plugin_mode(mode: str, output: Path) -> None:
|
|
216
|
+
"""Build plugin mode - Shadow Build with app plugins."""
|
|
217
|
+
print("Frontend mode: plugin (Shadow Build)")
|
|
218
|
+
print()
|
|
219
|
+
|
|
220
|
+
# Scan for app frontend plugins
|
|
221
|
+
apps_dir = Path.cwd() / "apps"
|
|
222
|
+
plugins: list[tuple[str, Path]] = []
|
|
223
|
+
|
|
224
|
+
if apps_dir.exists():
|
|
225
|
+
for app_dir in apps_dir.iterdir():
|
|
226
|
+
if app_dir.is_dir():
|
|
227
|
+
plugin_entry = app_dir / "frontend" / "index.ts"
|
|
228
|
+
if plugin_entry.exists():
|
|
229
|
+
plugins.append((app_dir.name, plugin_entry))
|
|
230
|
+
|
|
231
|
+
if not plugins:
|
|
232
|
+
print("No app frontend plugins found.")
|
|
233
|
+
print()
|
|
234
|
+
print("For plugin mode, apps should have:")
|
|
235
|
+
print(" apps/{app_name}/frontend/index.ts")
|
|
236
|
+
print()
|
|
237
|
+
print("Falling back to indie mode...")
|
|
238
|
+
_build_indie_mode(mode, output)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
print(f"Found {len(plugins)} app plugins:")
|
|
242
|
+
for name, path in plugins:
|
|
243
|
+
print(f" - {name}: {path}")
|
|
244
|
+
print()
|
|
245
|
+
|
|
246
|
+
# Generate temporary entry file
|
|
247
|
+
temp_dir = Path.cwd() / ".m" / "build"
|
|
248
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
|
|
250
|
+
entry_file = temp_dir / "entry.tsx"
|
|
251
|
+
_generate_plugin_entry(entry_file, plugins)
|
|
252
|
+
|
|
253
|
+
print(f"Generated entry: {entry_file}")
|
|
254
|
+
|
|
255
|
+
# Run vite build
|
|
256
|
+
try:
|
|
257
|
+
# Check for frontend base
|
|
258
|
+
frontend_dir = Path.cwd() / "frontend"
|
|
259
|
+
if not (frontend_dir / "package.json").exists():
|
|
260
|
+
print(
|
|
261
|
+
"Error: No frontend/package.json. Plugin mode requires base frontend."
|
|
262
|
+
)
|
|
263
|
+
raise SystemExit(1)
|
|
264
|
+
|
|
265
|
+
pkg_manager = "pnpm" if _has_command("pnpm") else "npm"
|
|
266
|
+
|
|
267
|
+
# Install deps
|
|
268
|
+
if not (frontend_dir / "node_modules").exists():
|
|
269
|
+
subprocess.run([pkg_manager, "install"], cwd=frontend_dir, check=True)
|
|
270
|
+
|
|
271
|
+
# Build with custom entry
|
|
272
|
+
env = {**os.environ, "VITE_ENTRY": str(entry_file)}
|
|
273
|
+
subprocess.run(
|
|
274
|
+
[pkg_manager, "run", "build"],
|
|
275
|
+
cwd=frontend_dir,
|
|
276
|
+
check=True,
|
|
277
|
+
env=env,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
print()
|
|
281
|
+
print("✓ Frontend build complete (plugin mode)")
|
|
282
|
+
|
|
283
|
+
except subprocess.CalledProcessError as e:
|
|
284
|
+
print(f"Error: Plugin build failed: {e.returncode}", file=sys.stderr)
|
|
285
|
+
raise SystemExit(e.returncode) from e
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _generate_plugin_entry(entry_file: Path, plugins: list[tuple[str, Path]]) -> None:
|
|
289
|
+
"""Generate temporary entry.tsx that imports all app plugins."""
|
|
290
|
+
imports = []
|
|
291
|
+
registrations = []
|
|
292
|
+
|
|
293
|
+
for app_name, plugin_path in plugins:
|
|
294
|
+
relative_path = plugin_path.relative_to(Path.cwd())
|
|
295
|
+
import_name = app_name.replace("-", "_").replace(".", "_")
|
|
296
|
+
imports.append(
|
|
297
|
+
f'import {{ register as register_{import_name} }} from "../../{relative_path}";'
|
|
298
|
+
)
|
|
299
|
+
registrations.append(f" register_{import_name}();")
|
|
300
|
+
|
|
301
|
+
content = f"""// Auto-generated plugin entry
|
|
302
|
+
// DO NOT EDIT - regenerated on each build
|
|
303
|
+
|
|
304
|
+
{chr(10).join(imports)}
|
|
305
|
+
|
|
306
|
+
// Register all app plugins
|
|
307
|
+
export function registerAllPlugins() {{
|
|
308
|
+
{chr(10).join(registrations)}
|
|
309
|
+
}}
|
|
310
|
+
|
|
311
|
+
// Auto-register on import
|
|
312
|
+
registerAllPlugins();
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
entry_file.write_text(content)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _has_command(cmd: str) -> bool:
|
|
319
|
+
"""Check if a command is available."""
|
|
320
|
+
try:
|
|
321
|
+
subprocess.run(
|
|
322
|
+
[cmd, "--version"],
|
|
323
|
+
capture_output=True,
|
|
324
|
+
check=True,
|
|
325
|
+
)
|
|
326
|
+
return True
|
|
327
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def build_docker_command(
|
|
332
|
+
tag: Annotated[
|
|
333
|
+
str | None,
|
|
334
|
+
cyclopts.Parameter(name="--tag", help="Image tag (default: from config)"),
|
|
335
|
+
] = None,
|
|
336
|
+
dockerfile: Annotated[
|
|
337
|
+
str,
|
|
338
|
+
cyclopts.Parameter(name="--file", help="Path to Dockerfile"),
|
|
339
|
+
] = "Dockerfile",
|
|
340
|
+
context: Annotated[
|
|
341
|
+
str,
|
|
342
|
+
cyclopts.Parameter(name="--context", help="Build context path"),
|
|
343
|
+
] = ".",
|
|
344
|
+
no_cache: Annotated[
|
|
345
|
+
bool,
|
|
346
|
+
cyclopts.Parameter(name="--no-cache", help="Build without cache"),
|
|
347
|
+
] = False,
|
|
348
|
+
push: Annotated[
|
|
349
|
+
bool,
|
|
350
|
+
cyclopts.Parameter(name="--push", help="Push image after build"),
|
|
351
|
+
] = False,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Build Docker image for the application.
|
|
354
|
+
|
|
355
|
+
Wraps `docker build` with Framework M defaults.
|
|
356
|
+
Reads image name and version from framework_config.toml if not specified.
|
|
357
|
+
|
|
358
|
+
Examples:
|
|
359
|
+
m build:docker # Use config defaults
|
|
360
|
+
m build:docker --tag myapp:v1 # Custom tag
|
|
361
|
+
m build:docker --push # Build and push
|
|
362
|
+
"""
|
|
363
|
+
# Determine tag
|
|
364
|
+
image_tag = tag or get_default_image_name()
|
|
365
|
+
|
|
366
|
+
# Check Dockerfile exists
|
|
367
|
+
dockerfile_path = Path(dockerfile)
|
|
368
|
+
if not dockerfile_path.exists():
|
|
369
|
+
print(f"Error: Dockerfile not found: {dockerfile}", file=sys.stderr)
|
|
370
|
+
print()
|
|
371
|
+
print("Create a Dockerfile or specify path with --file")
|
|
372
|
+
raise SystemExit(1)
|
|
373
|
+
|
|
374
|
+
# Build command
|
|
375
|
+
cmd = build_docker_cmd(
|
|
376
|
+
tag=image_tag,
|
|
377
|
+
dockerfile=dockerfile,
|
|
378
|
+
context=context,
|
|
379
|
+
no_cache=no_cache,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
print("Docker Build")
|
|
383
|
+
print("=" * 40)
|
|
384
|
+
print()
|
|
385
|
+
print(f" Image: {image_tag}")
|
|
386
|
+
print(f" Dockerfile: {dockerfile}")
|
|
387
|
+
print(f" Context: {context}")
|
|
388
|
+
if no_cache:
|
|
389
|
+
print(" Cache: disabled")
|
|
390
|
+
print()
|
|
391
|
+
print(f"Running: {' '.join(cmd)}")
|
|
392
|
+
print()
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
result = subprocess.run(cmd)
|
|
396
|
+
if result.returncode != 0:
|
|
397
|
+
raise SystemExit(result.returncode)
|
|
398
|
+
|
|
399
|
+
print()
|
|
400
|
+
print(f"✓ Built image: {image_tag}")
|
|
401
|
+
|
|
402
|
+
# Push if requested
|
|
403
|
+
if push:
|
|
404
|
+
print()
|
|
405
|
+
print(f"Pushing: {image_tag}")
|
|
406
|
+
push_result = subprocess.run(["docker", "push", image_tag])
|
|
407
|
+
if push_result.returncode != 0:
|
|
408
|
+
raise SystemExit(push_result.returncode)
|
|
409
|
+
print(f"✓ Pushed: {image_tag}")
|
|
410
|
+
|
|
411
|
+
except FileNotFoundError:
|
|
412
|
+
print("Error: docker not found. Install Docker.", file=sys.stderr)
|
|
413
|
+
raise SystemExit(1) from None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
__all__ = [
|
|
417
|
+
"build_command",
|
|
418
|
+
"build_docker_cmd",
|
|
419
|
+
"build_docker_command",
|
|
420
|
+
"get_default_image_name",
|
|
421
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Dev CLI Command - Development server with hot reload.
|
|
2
|
+
|
|
3
|
+
This module provides the `m dev` command for running the full development
|
|
4
|
+
stack with automatic hot reload using honcho for process management.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- Uses honcho.manager.Manager for concurrent process management
|
|
8
|
+
- Generates Procfile entries dynamically
|
|
9
|
+
- All processes have hot reload enabled
|
|
10
|
+
- Graceful shutdown on Ctrl+C
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
m dev # Backend + Frontend
|
|
14
|
+
m dev --studio # + Studio on port 9999
|
|
15
|
+
m dev --app myapp:app # Custom backend module
|
|
16
|
+
m dev --frontend-dir ./ui # Custom frontend path
|
|
17
|
+
m dev --no-frontend # Backend only
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import signal
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Annotated
|
|
27
|
+
|
|
28
|
+
import cyclopts
|
|
29
|
+
from honcho.manager import Manager
|
|
30
|
+
from honcho.printer import Printer
|
|
31
|
+
|
|
32
|
+
from framework_m_studio.cli.studio import DEFAULT_STUDIO_APP
|
|
33
|
+
|
|
34
|
+
# Default ports
|
|
35
|
+
DEFAULT_BACKEND_PORT = 8888
|
|
36
|
+
DEFAULT_FRONTEND_PORT = 5173
|
|
37
|
+
DEFAULT_STUDIO_PORT = 9999
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_pythonpath() -> str:
|
|
41
|
+
"""Get PYTHONPATH with src/ directory included."""
|
|
42
|
+
cwd = Path.cwd()
|
|
43
|
+
src_path = cwd / "src"
|
|
44
|
+
|
|
45
|
+
paths = []
|
|
46
|
+
if src_path.exists():
|
|
47
|
+
paths.append(str(src_path))
|
|
48
|
+
paths.append(str(cwd))
|
|
49
|
+
|
|
50
|
+
existing = os.environ.get("PYTHONPATH", "")
|
|
51
|
+
if existing:
|
|
52
|
+
paths.append(existing)
|
|
53
|
+
|
|
54
|
+
return os.pathsep.join(paths)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _find_app(explicit_app: str | None = None) -> str:
|
|
58
|
+
"""Find the Litestar app to run."""
|
|
59
|
+
if explicit_app:
|
|
60
|
+
return explicit_app
|
|
61
|
+
|
|
62
|
+
cwd = Path.cwd()
|
|
63
|
+
|
|
64
|
+
if (cwd / "app.py").exists():
|
|
65
|
+
return "app:app"
|
|
66
|
+
if (cwd / "app" / "__init__.py").exists():
|
|
67
|
+
return "app:app"
|
|
68
|
+
if (cwd / "src" / "app" / "__init__.py").exists():
|
|
69
|
+
return "src.app:app"
|
|
70
|
+
if (cwd / "main.py").exists():
|
|
71
|
+
return "main:app"
|
|
72
|
+
|
|
73
|
+
return "framework_m_standard.adapters.web.app:create_app"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_frontend_exists(frontend_dir: Path) -> bool:
|
|
77
|
+
"""Check if frontend directory exists and is valid."""
|
|
78
|
+
return frontend_dir.exists() and (frontend_dir / "package.json").exists()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dev_command(
|
|
82
|
+
app: Annotated[
|
|
83
|
+
str | None,
|
|
84
|
+
cyclopts.Parameter(
|
|
85
|
+
name="--app",
|
|
86
|
+
help="Backend app path (module:attribute format)",
|
|
87
|
+
),
|
|
88
|
+
] = None,
|
|
89
|
+
host: Annotated[
|
|
90
|
+
str,
|
|
91
|
+
cyclopts.Parameter(name="--host", help="Host to bind to"),
|
|
92
|
+
] = "127.0.0.1",
|
|
93
|
+
port: Annotated[
|
|
94
|
+
int,
|
|
95
|
+
cyclopts.Parameter(name="--port", help="Backend port"),
|
|
96
|
+
] = DEFAULT_BACKEND_PORT,
|
|
97
|
+
frontend_dir: Annotated[
|
|
98
|
+
Path,
|
|
99
|
+
cyclopts.Parameter(
|
|
100
|
+
name="--frontend-dir",
|
|
101
|
+
help="Frontend directory path",
|
|
102
|
+
),
|
|
103
|
+
] = Path("frontend"),
|
|
104
|
+
frontend_port: Annotated[
|
|
105
|
+
int,
|
|
106
|
+
cyclopts.Parameter(name="--frontend-port", help="Frontend dev server port"),
|
|
107
|
+
] = DEFAULT_FRONTEND_PORT,
|
|
108
|
+
studio: Annotated[
|
|
109
|
+
bool,
|
|
110
|
+
cyclopts.Parameter(
|
|
111
|
+
name="--studio",
|
|
112
|
+
help="Also start Studio on port 9999",
|
|
113
|
+
),
|
|
114
|
+
] = False,
|
|
115
|
+
studio_port: Annotated[
|
|
116
|
+
int,
|
|
117
|
+
cyclopts.Parameter(name="--studio-port", help="Studio port"),
|
|
118
|
+
] = DEFAULT_STUDIO_PORT,
|
|
119
|
+
no_frontend: Annotated[
|
|
120
|
+
bool,
|
|
121
|
+
cyclopts.Parameter(
|
|
122
|
+
name="--no-frontend",
|
|
123
|
+
help="Skip frontend, run backend only",
|
|
124
|
+
),
|
|
125
|
+
] = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Start development servers with hot reload.
|
|
128
|
+
|
|
129
|
+
Runs backend (uvicorn --reload) and frontend (pnpm dev) concurrently
|
|
130
|
+
using honcho for process management.
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
m dev # Backend + Frontend
|
|
134
|
+
m dev --studio # + Studio
|
|
135
|
+
m dev --app myapp:create_app # Custom backend
|
|
136
|
+
m dev --no-frontend # Backend only
|
|
137
|
+
"""
|
|
138
|
+
# Find app path
|
|
139
|
+
app_path = _find_app(app)
|
|
140
|
+
|
|
141
|
+
# Set up environment
|
|
142
|
+
env = os.environ.copy()
|
|
143
|
+
env["PYTHONPATH"] = _get_pythonpath()
|
|
144
|
+
|
|
145
|
+
# Build process commands
|
|
146
|
+
processes: dict[str, str] = {}
|
|
147
|
+
|
|
148
|
+
# Backend with hot reload
|
|
149
|
+
backend_cmd = (
|
|
150
|
+
f"{sys.executable} -m uvicorn {app_path} --host {host} --port {port} --reload"
|
|
151
|
+
)
|
|
152
|
+
processes["backend"] = backend_cmd
|
|
153
|
+
|
|
154
|
+
# Frontend dev server
|
|
155
|
+
if not no_frontend:
|
|
156
|
+
if _check_frontend_exists(frontend_dir):
|
|
157
|
+
# Use VITE_PORT env var to set port
|
|
158
|
+
frontend_cmd = f"cd {frontend_dir} && pnpm dev --port {frontend_port}"
|
|
159
|
+
processes["frontend"] = frontend_cmd
|
|
160
|
+
else:
|
|
161
|
+
print(f"⚠ Frontend not found at {frontend_dir}, skipping...")
|
|
162
|
+
print(" Run 'm init:frontend' to scaffold frontend\n")
|
|
163
|
+
|
|
164
|
+
# Studio (optional)
|
|
165
|
+
if studio:
|
|
166
|
+
studio_cmd = (
|
|
167
|
+
f"{sys.executable} -m uvicorn {DEFAULT_STUDIO_APP} "
|
|
168
|
+
f"--host {host} --port {studio_port} --reload"
|
|
169
|
+
)
|
|
170
|
+
processes["studio"] = studio_cmd
|
|
171
|
+
|
|
172
|
+
if not processes:
|
|
173
|
+
print("No processes to run!")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Print startup info
|
|
177
|
+
print("=" * 50)
|
|
178
|
+
print(" Framework M Development Server")
|
|
179
|
+
print("=" * 50)
|
|
180
|
+
print()
|
|
181
|
+
for name, _cmd in processes.items():
|
|
182
|
+
if name == "backend":
|
|
183
|
+
print(f" 🚀 Backend: http://{host}:{port}")
|
|
184
|
+
elif name == "frontend":
|
|
185
|
+
print(f" 🎨 Frontend: http://{host}:{frontend_port}")
|
|
186
|
+
elif name == "studio":
|
|
187
|
+
print(f" 🎯 Studio: http://{host}:{studio_port}")
|
|
188
|
+
print()
|
|
189
|
+
print(" Press Ctrl+C to stop all servers")
|
|
190
|
+
print("=" * 50)
|
|
191
|
+
print()
|
|
192
|
+
|
|
193
|
+
# Create honcho manager with correct v2.0 API
|
|
194
|
+
manager = Manager(Printer(output=sys.stdout))
|
|
195
|
+
|
|
196
|
+
# Add processes
|
|
197
|
+
for name, cmd in processes.items():
|
|
198
|
+
manager.add_process(name, cmd, env=env)
|
|
199
|
+
|
|
200
|
+
# Handle graceful shutdown
|
|
201
|
+
def signal_handler(signum: int, frame: object) -> None:
|
|
202
|
+
print("\n\nStopping all servers...")
|
|
203
|
+
manager.terminate()
|
|
204
|
+
|
|
205
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
206
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
207
|
+
|
|
208
|
+
# Run all processes
|
|
209
|
+
manager.loop()
|
|
210
|
+
|
|
211
|
+
print("\n✓ All servers stopped.")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = ["dev_command"]
|