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.
@@ -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"]