Skip to content

Commit b6dc437

Browse files
committed
feat(cli): add 'orb server ui-export' to copy the SPA bundle to a directory
Adds a new `orb server ui-export --dest <dir>` subcommand under the existing `server` group. It locates the compiled SPA bundle via the shared `_resolve_static_dir()` seam in `orb.ui.app` (same source of truth used by the embedded-server route) and copies it with `shutil.copytree`. This lets operators export the built frontend to any local directory they can then push to a CDN, S3, GCS, or any static host using their own tooling (aws s3 cp --recursive, gsutil rsync, rsync, etc.) — keeping the command provider-agnostic and trivial. - `--dest` (required): target directory - `--force`: allow overwrite into a non-empty / pre-existing destination - Returns a success dict with dest path and file count, or a clear error when the bundle is missing (hint: pip install 'orb-py[ui]' + make ui-build) - Four unit tests covering: happy path, missing bundle, non-empty-without-force, and non-empty-with-force - pyright: 0 errors on all touched files
1 parent 8b69a98 commit b6dc437

4 files changed

Lines changed: 179 additions & 0 deletions

File tree

src/orb/cli/args.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,22 @@ def _add_server_start_args(p):
608608
)
609609
add_global_arguments(server_reload_cmd)
610610

611+
server_ui_export = server_subparsers.add_parser(
612+
"ui-export",
613+
help="Copy the compiled SPA bundle to a local directory for CDN / static-host serving",
614+
)
615+
add_global_arguments(server_ui_export)
616+
server_ui_export.add_argument(
617+
"--dest",
618+
required=True,
619+
help="Target directory to copy the SPA bundle into",
620+
)
621+
server_ui_export.add_argument(
622+
"--force",
623+
action="store_true",
624+
help="Allow overwriting into a non-empty or already-existing destination",
625+
)
626+
611627
# Infrastructure
612628
infrastructure_parser = subparsers.add_parser("infrastructure", help="Infrastructure discovery")
613629
resource_parsers["infrastructure"] = infrastructure_parser

src/orb/cli/registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ async def _handle_mcp_tools(args: argparse.Namespace) -> Any:
180180
handle_server_start,
181181
handle_server_status,
182182
handle_server_stop,
183+
handle_server_ui_export,
183184
)
184185

185186
register("server", "start", handle_server_start)
@@ -188,6 +189,7 @@ async def _handle_mcp_tools(args: argparse.Namespace) -> Any:
188189
register("server", "restart", handle_server_restart)
189190
register("server", "reload", handle_server_reload)
190191
register("server", "logs", handle_server_logs)
192+
register("server", "ui-export", handle_server_ui_export)
191193

192194
# --- templates ---
193195
from orb.interface.template_command_handlers import (

src/orb/interface/server_command_handlers.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,64 @@ async def handle_server_logs(args) -> dict[str, Any]:
417417
"log_file": log_file,
418418
"tail": daemon_mod.tail_log(log_file=log_file, lines=int(lines)),
419419
}
420+
421+
422+
def _ui_resolve_static_dir():
423+
"""Thin wrapper around ``orb.ui.app._resolve_static_dir``.
424+
425+
Importing lazily keeps the heavy Reflex/page graph out of the CLI process
426+
when the UI extras are not installed. The wrapper lives in this module so
427+
tests can patch ``orb.interface.server_command_handlers._ui_resolve_static_dir``
428+
without having to import ``orb.ui.app`` (and its reflex/page side-effects).
429+
"""
430+
from orb.ui.app import _resolve_static_dir
431+
432+
return _resolve_static_dir()
433+
434+
435+
@handle_interface_exceptions(context="server_ui_export", interface_type="cli")
436+
async def handle_server_ui_export(args) -> dict[str, Any]:
437+
"""Copy the compiled SPA bundle to a local directory for CDN / static-host serving.
438+
439+
Locates the bundle via ``orb.ui.app._resolve_static_dir()`` (single source
440+
of truth shared with the embedded server route) and copies it with
441+
``shutil.copytree``.
442+
443+
# ponytail: local dir only; users pipe to s3/gcs with their own tooling
444+
"""
445+
import shutil
446+
from pathlib import Path
447+
448+
dest_arg: str = getattr(args, "dest", None) or ""
449+
force: bool = getattr(args, "force", False)
450+
451+
static_dir: Path | None = _ui_resolve_static_dir()
452+
if static_dir is None:
453+
return {
454+
"status": "error",
455+
"message": (
456+
"UI bundle not found — no compiled SPA is available. "
457+
"Install the UI extras and build the bundle: "
458+
"pip install 'orb-py[ui]' then run 'make ui-build'."
459+
),
460+
}
461+
462+
dest = Path(dest_arg).resolve()
463+
if dest.exists() and any(dest.iterdir()) and not force:
464+
return {
465+
"status": "error",
466+
"message": (
467+
f"Destination '{dest}' already exists and is not empty. "
468+
"Use --force to overwrite."
469+
),
470+
}
471+
472+
shutil.copytree(str(static_dir), str(dest), dirs_exist_ok=force)
473+
474+
file_count = sum(1 for _ in dest.rglob("*") if _.is_file())
475+
return {
476+
"status": "ok",
477+
"dest": str(dest),
478+
"file_count": file_count,
479+
"message": f"SPA bundle exported to '{dest}' ({file_count} files).",
480+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Unit tests for the ``orb server ui-export`` CLI handler."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
import pytest
10+
11+
12+
def _args(dest: str, force: bool = False) -> argparse.Namespace:
13+
return argparse.Namespace(dest=dest, force=force)
14+
15+
16+
@pytest.mark.asyncio
17+
async def test_export_copies_bundle_to_dest(tmp_path: Path) -> None:
18+
"""Handler copies the bundle to the destination and reports file count."""
19+
# Build a minimal fake static bundle in a source tmp dir.
20+
source = tmp_path / "static"
21+
source.mkdir()
22+
(source / "index.html").write_text("<html/>")
23+
(source / "assets").mkdir()
24+
(source / "assets" / "main.js").write_text("// js")
25+
26+
dest = tmp_path / "export"
27+
28+
from orb.interface.server_command_handlers import handle_server_ui_export
29+
30+
with patch(
31+
"orb.interface.server_command_handlers._ui_resolve_static_dir", return_value=source
32+
):
33+
result = await handle_server_ui_export(_args(str(dest)))
34+
35+
assert result["status"] == "ok"
36+
assert result["dest"] == str(dest)
37+
assert result["file_count"] == 2
38+
assert (dest / "index.html").is_file()
39+
assert (dest / "assets" / "main.js").is_file()
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_export_returns_error_when_bundle_missing() -> None:
44+
"""Handler returns a clear error dict when _resolve_static_dir() returns None."""
45+
from orb.interface.server_command_handlers import handle_server_ui_export
46+
47+
with patch(
48+
"orb.interface.server_command_handlers._ui_resolve_static_dir", return_value=None
49+
):
50+
result = await handle_server_ui_export(_args("/tmp/irrelevant"))
51+
52+
assert result["status"] == "error"
53+
assert "bundle not found" in result["message"].lower()
54+
assert "pip install" in result["message"]
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_export_rejects_non_empty_dest_without_force(tmp_path: Path) -> None:
59+
"""Handler refuses to overwrite a non-empty destination unless --force is set."""
60+
source = tmp_path / "static"
61+
source.mkdir()
62+
(source / "index.html").write_text("<html/>")
63+
64+
dest = tmp_path / "existing"
65+
dest.mkdir()
66+
(dest / "stale.txt").write_text("old content")
67+
68+
from orb.interface.server_command_handlers import handle_server_ui_export
69+
70+
with patch(
71+
"orb.interface.server_command_handlers._ui_resolve_static_dir", return_value=source
72+
):
73+
result = await handle_server_ui_export(_args(str(dest), force=False))
74+
75+
assert result["status"] == "error"
76+
assert "--force" in result["message"]
77+
# Original content should be untouched.
78+
assert (dest / "stale.txt").is_file()
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_export_overwrites_non_empty_dest_with_force(tmp_path: Path) -> None:
83+
"""Handler merges/overwrites into an existing destination when --force is set."""
84+
source = tmp_path / "static"
85+
source.mkdir()
86+
(source / "index.html").write_text("<html/>")
87+
88+
dest = tmp_path / "existing"
89+
dest.mkdir()
90+
(dest / "stale.txt").write_text("old content")
91+
92+
from orb.interface.server_command_handlers import handle_server_ui_export
93+
94+
with patch(
95+
"orb.interface.server_command_handlers._ui_resolve_static_dir", return_value=source
96+
):
97+
result = await handle_server_ui_export(_args(str(dest), force=True))
98+
99+
assert result["status"] == "ok"
100+
assert (dest / "index.html").is_file()

0 commit comments

Comments
 (0)