Skip to content

Commit e637973

Browse files
Run tui on first invocation
1 parent 0662430 commit e637973

File tree

7 files changed

+71
-130
lines changed

7 files changed

+71
-130
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "open-edison"
3-
version = "0.1.55"
3+
version = "0.1.56"
44
description = "Open-source MCP security, aggregation, and monitoring. Single-user, self-hosted MCP proxy."
55
readme = "README.md"
66
authors = [

src/cli.py

Lines changed: 18 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@
77
import argparse
88
import asyncio
99
import os
10-
import subprocess as _subprocess
11-
from contextlib import suppress
1210
from pathlib import Path
1311
from typing import Any, NoReturn
1412

1513
from loguru import logger as _log # type: ignore[reportMissingImports]
1614

1715
from src.config import Config, get_config_dir, get_config_json_path
1816
from src.mcp_importer.cli import run_cli
17+
from src.setup_tui.main import run_import_tui
1918
from src.server import OpenEdisonProxy
2019

2120
log: Any = _log
@@ -37,6 +36,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
3736
parser.add_argument(
3837
"--port", type=int, help="Server port override (FastMCP on port, FastAPI on port+1)"
3938
)
39+
# For the setup wizard
40+
parser.add_argument(
41+
"--wizard-dry-run",
42+
action="store_true",
43+
help="(For the setup wizard) Show changes without writing to config.json",
44+
)
45+
parser.add_argument(
46+
"--wizard-skip-oauth",
47+
action="store_true",
48+
help="(For the setup wizard) Skip OAuth for remote servers (they will be omitted from import)",
49+
)
4050
# Website runs from packaged assets by default; no extra website flags
4151

4252
# Subcommands (extensible)
@@ -88,89 +98,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
8898
return parser.parse_args(argv)
8999

90100

91-
def _spawn_frontend_dev( # noqa: C901 - pragmatic complexity for env probing
92-
port: int,
93-
override_dir: Path | None = None,
94-
config_dir: Path | None = None,
95-
) -> tuple[int, _subprocess.Popen[bytes] | None]:
96-
"""Try to start the frontend dev server by running `npm run dev`.
97-
98-
Search order for working directory:
99-
1) Packaged project path: <pkg_root>/frontend
100-
2) Current working directory (if it contains a package.json)
101-
"""
102-
candidates: list[Path] = []
103-
# Prefer packaged static assets; if present, the backend serves /dashboard
104-
static_candidates = [
105-
Path(__file__).parent / "frontend_dist", # inside package dir
106-
Path(__file__).parent.parent / "frontend_dist", # site-packages root
107-
]
108-
static_dir = next((p for p in static_candidates if p.exists() and p.is_dir()), None)
109-
if static_dir is not None:
110-
log.info(
111-
f"Packaged dashboard detected at {static_dir}. It will be served at /dashboard by the API server."
112-
)
113-
# No separate website process needed. Return sentinel port (-1) so caller knows not to warn.
114-
return (-1, None)
115-
116-
if static_dir is None:
117-
raise RuntimeError(
118-
"No packaged dashboard detected. The website will be served from the frontend directory."
119-
)
120-
121-
pkg_frontend_candidates = [
122-
Path(__file__).parent / "frontend", # inside package dir
123-
Path(__file__).parent.parent / "frontend", # site-packages root
124-
]
125-
if override_dir is not None:
126-
candidates.append(override_dir)
127-
for pf in pkg_frontend_candidates:
128-
if pf.exists():
129-
candidates.append(pf)
130-
if config_dir is not None and (config_dir / "package.json").exists():
131-
candidates.append(config_dir)
132-
cwd_pkg = Path.cwd()
133-
if (cwd_pkg / "package.json").exists():
134-
candidates.append(cwd_pkg)
135-
136-
if not candidates:
137-
log.warning(
138-
"No frontend directory found (no packaged frontend and no package.json in CWD). Skipping website."
139-
)
140-
return (port, None)
141-
142-
for candidate in candidates:
143-
try:
144-
# If no package.json but directory exists, try a basic npm i per user request
145-
if not (candidate / "package.json").exists():
146-
log.info(f"No package.json in {candidate}. Running 'npm i' as best effort...")
147-
_ = _subprocess.call(["npm", "i"], cwd=str(candidate))
148-
149-
# Install deps if needed
150-
if (
151-
not (candidate / "node_modules").exists()
152-
and (candidate / "package-lock.json").exists()
153-
):
154-
log.info(f"Installing frontend dependencies with npm ci in {candidate}...")
155-
r_install = _subprocess.call(["npm", "ci"], cwd=str(candidate))
156-
if r_install != 0:
157-
log.error("Failed to install frontend dependencies")
158-
continue
159-
160-
log.info(f"Starting frontend dev server in {candidate} on port {port}...")
161-
cmd_default = ["npm", "run", "dev", "--", "--port", str(port)]
162-
proc = _subprocess.Popen(cmd_default, cwd=str(candidate))
163-
return (port, proc)
164-
except FileNotFoundError:
165-
log.error("npm not found. Please install Node.js to run the website dev server.")
166-
return (port, None)
167-
168-
# If all candidates failed
169-
return (port, None)
170-
171-
172101
async def _run_server(args: Any) -> None:
173-
# TODO check this works as we want it to
174102
# Resolve config dir and expose via env for the rest of the app
175103
config_dir_arg = getattr(args, "config_dir", None)
176104
if config_dir_arg is not None:
@@ -186,44 +114,26 @@ async def _run_server(args: Any) -> None:
186114
log.info(f"Using config directory: {config_dir}")
187115
proxy = OpenEdisonProxy(host=host, port=port)
188116

189-
# Website served from packaged assets by default; still detect and log
190-
frontend_proc = None
191-
used_port, frontend_proc = _spawn_frontend_dev(5173, None, config_dir)
192-
if frontend_proc is None and used_port == -1:
193-
log.info("Frontend is being served from packaged assets at /dashboard")
194-
195117
try:
196118
await proxy.start()
197119
_ = await asyncio.Event().wait()
198120
except KeyboardInterrupt:
199121
log.info("Received shutdown signal")
200-
finally:
201-
if frontend_proc is not None:
202-
with suppress(Exception):
203-
frontend_proc.terminate()
204-
_ = frontend_proc.wait(timeout=5)
205-
with suppress(Exception):
206-
frontend_proc.kill()
207-
208-
209-
def _run_website(port: int, website_dir: Path | None = None) -> int:
210-
# Use the same spawning logic, then return 0 if started or 1 if failed
211-
_, proc = _spawn_frontend_dev(port, website_dir)
212-
return 0 if proc is not None else 1
213122

214123

215124
def main(argv: list[str] | None = None) -> NoReturn: # noqa: C901
216125
args = _parse_args(argv)
217126

218-
if getattr(args, "command", None) == "website":
219-
exit_code = _run_website(port=args.port, website_dir=getattr(args, "dir", None))
220-
raise SystemExit(exit_code)
127+
if args.command is None:
128+
args.command = "run"
221129

222-
if getattr(args, "command", None) == "import-mcp":
130+
if args.command == "import-mcp":
223131
result_code = run_cli(argv)
224132
raise SystemExit(result_code)
225133

226-
# default: run server (top-level flags)
134+
# Run import tui if necessary
135+
run_import_tui(args)
136+
227137
try:
228138
asyncio.run(_run_server(args))
229139
raise SystemExit(0)

src/mcp_importer/api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ async def _list_tools_only() -> Any:
237237
return True
238238
# NOT_REQUIRED: quick unauthenticated ping
239239
# TODO: In debug mode, do not suppress child process output.
240-
questionary.print(f"Testing connection to '{server.name}'...", style="bold fg:ansigreen")
240+
questionary.print(
241+
f"Testing connection to '{server.name}'...", style="bold fg:ansigreen"
242+
)
241243
log.debug(f"Establishing contact with remote server '{server.name}'")
242244
async with asyncio.timeout(connection_timeout):
243245
async with FastMCPClient(

src/mcp_importer/parsers.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,28 @@ def _collect_nested(data: dict[str, Any], default_enabled: bool) -> list[Any]:
159159
return results
160160

161161

162-
def parse_mcp_like_json(data: dict[str, Any], default_enabled: bool = True) -> list[Any]:
162+
def deduplicate_by_name(servers: list[MCPServerConfig]) -> list[MCPServerConfig]:
163+
result: list[MCPServerConfig] = []
164+
names = set()
165+
for server in servers:
166+
if server.name not in names:
167+
names.add(server.name)
168+
result.append(server)
169+
return result
170+
171+
172+
def parse_mcp_like_json(
173+
data: dict[str, Any], default_enabled: bool = True
174+
) -> list[MCPServerConfig]:
163175
# First, try top-level keys
164176
top_level = _collect_top_level(data, default_enabled)
177+
res: list[MCPServerConfig] = []
165178
if top_level:
166-
return top_level
167-
168-
# Then, try nested structures heuristically
169-
nested = _collect_nested(data, default_enabled)
170-
if not nested:
171-
log.debug("No MCP-like entries detected in provided data")
172-
return nested
179+
res = top_level
180+
else:
181+
# Then, try nested structures heuristically
182+
nested = _collect_nested(data, default_enabled)
183+
if not nested:
184+
log.debug("No MCP-like entries detected in provided data")
185+
res = nested
186+
return deduplicate_by_name(res)

src/setup_tui/main.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import argparse
22
import asyncio
3+
import contextlib
34
import sys
5+
from collections.abc import Generator
46

57
import questionary
68
from loguru import logger as log
79

810
import src.oauth_manager as oauth_mod
9-
from src.config import MCPServerConfig
11+
from src.config import MCPServerConfig, get_config_dir
1012
from src.mcp_importer.api import (
1113
CLIENT,
1214
authorize_server_oauth,
@@ -100,7 +102,7 @@ def handle_mcp_source( # noqa: C901
100102
verified_configs.append(config)
101103
else:
102104
print(
103-
f"The configuration for {config.name} is not valid. Please check the configuration and try again."
105+
f"Verification failed for the configuration of {config.name}. Please check the configuration and try again."
104106
)
105107

106108
return verified_configs
@@ -171,13 +173,18 @@ def show_manual_setup_screen() -> None:
171173
print(after_text)
172174

173175

174-
def run(*, dry_run: bool = False, skip_oauth: bool = False) -> None: # noqa: C901
175-
"""Run the complete setup process."""
176-
# Suppress loguru output for a cleaner TUI experience
177-
import contextlib
178-
176+
@contextlib.contextmanager
177+
def suppress_loguru_output() -> Generator[None, None, None]:
178+
"""Suppress loguru output."""
179179
with contextlib.suppress(Exception):
180180
log.remove()
181+
yield
182+
log.add(sys.stdout, level="INFO")
183+
184+
185+
@suppress_loguru_output()
186+
def run(*, dry_run: bool = False, skip_oauth: bool = False) -> None: # noqa: C901
187+
"""Run the complete setup process."""
181188

182189
# Route oauth_manager's log calls to questionary for TUI output
183190
class _TuiLogger:
@@ -234,6 +241,18 @@ def error(self, msg: object, *args: object, **kwargs: object) -> None:
234241
log.add(sys.stdout, level="INFO")
235242

236243

244+
# Triggered from cli.py
245+
def run_import_tui(args: argparse.Namespace) -> None:
246+
"""Run the import TUI, if necessary."""
247+
# Find config dir, check if ".setup_tui_run" exists
248+
config_dir = get_config_dir()
249+
setup_tui_run_file = config_dir / ".setup_tui_run"
250+
if not setup_tui_run_file.exists():
251+
run(dry_run=args.wizard_dry_run, skip_oauth=args.wizard_skip_oauth)
252+
253+
setup_tui_run_file.touch()
254+
255+
237256
def main(argv: list[str] | None = None) -> int:
238257
parser = argparse.ArgumentParser(description="Open Edison Setup TUI")
239258
parser.add_argument("--dry-run", action="store_true", help="Preview actions without writing")

src/tools/io.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from __future__ import annotations
2-
31
import os
42
from collections.abc import Iterator
53
from contextlib import contextmanager
@@ -35,5 +33,3 @@ def suppress_fds(*, suppress_stdout: bool = False, suppress_stderr: bool = True)
3533
os.dup2(backup, fd)
3634
finally:
3735
os.close(backup)
38-
39-

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)