diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 1bdaf71..96df123 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -1084,9 +1084,21 @@ def print_banner(): ╚══════════════════════════════════════════════════════════╝\033[0m""" print(banner) +def _stdin_is_interactive() -> bool: + """False for Studio/CI, which inherit an stdin that may never write.""" + try: + return sys.stdin is not None and sys.stdin.isatty() + except Exception: + return False + + def _prompt(message: str, default: str = "") -> str: default_hint = f" [{default}]" if default else "" sys.stdout.write(f"\033[32m? \033[1;37m{message}:\033[0m{default_hint} ") + if not _stdin_is_interactive(): + sys.stdout.write(f"{default}\n") + sys.stdout.flush() + return default sys.stdout.flush() try: value = sys.stdin.readline().strip() @@ -1202,6 +1214,10 @@ def _upsert_env_var(lines, key, value): def _prompt_yes_no(message: str, default_yes: bool = True) -> bool: hint = "Y/n" if default_yes else "y/N" sys.stdout.write(f"\033[32m? \033[1;37m{message}:\033[0m ({hint}) ") + if not _stdin_is_interactive(): + sys.stdout.write(("yes" if default_yes else "no") + "\n") + sys.stdout.flush() + return default_yes sys.stdout.flush() try: ans = sys.stdin.readline().strip().lower() @@ -1262,6 +1278,9 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa # 2. Overwrite check if os.path.exists(name): + if not _stdin_is_interactive(): + print(f"Error: Directory '{name}' already exists.") + sys.exit(1) sys.stdout.write(f"\033[32m? \033[1;37mDirectory '{name}' already exists. Overwrite?\033[0m (Yes/No) [No]: ") sys.stdout.flush() ans = sys.stdin.readline().strip().lower() @@ -1276,7 +1295,7 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa else: template = _resolve_template(template) - # 4. Description and Author + # 4. Description and Author (defaults when stdin is not a TTY) description = _prompt("Description", "My awesome MCP server") author = _prompt("Author", "developer") diff --git a/tests/test_cli.py b/tests/test_cli.py index 7aa35f1..0a7fc3a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,14 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +class _TtyInput(io.StringIO): + """Scripted answers for prompts, which only read stdin when it is a TTY.""" + + def isatty(self): + return True + + from nitrostack.cli.main import ( DEFAULT_MCP_PORT, DEFAULT_PROJECT_NAME, @@ -124,7 +132,7 @@ def test_init_with_name_and_explicit_template(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("A test server\nTester\n") + sys.stdin = _TtyInput("A test server\nTester\n") init_project("cli-demo", template="python-starter", skip_install=True) project = os.path.join(tmp, "cli-demo") assert os.path.isfile(os.path.join(project, "main.py")) @@ -151,7 +159,7 @@ def test_init_prompts_for_name_when_omitted(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("prompted-server\n\n\n") + sys.stdin = _TtyInput("prompted-server\n\n\n") init_project(None, template="python-starter", skip_install=True) assert os.path.isdir(os.path.join(tmp, "prompted-server")) print("Success! Missing name is asked on the next readline.") @@ -167,7 +175,7 @@ def test_init_default_name_when_blank_readline(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("\n\n\n") + sys.stdin = _TtyInput("\n\n\n") init_project(None, template="python-pizzaz", skip_install=True) assert os.path.isdir(os.path.join(tmp, DEFAULT_PROJECT_NAME)) env_text = open(os.path.join(tmp, DEFAULT_PROJECT_NAME, ".env"), encoding="utf-8").read() @@ -191,7 +199,7 @@ def test_init_python_oauth_template(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("\n\n") + sys.stdin = _TtyInput("\n\n") init_project("oauth-demo", template="python-oauth", skip_install=True) project = os.path.join(tmp, "oauth-demo") assert os.path.isfile(os.path.join(project, "OAUTH_SETUP.md")) @@ -280,7 +288,7 @@ def test_init_port_and_widget_flags_override_defaults(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("\n\n") + sys.stdin = _TtyInput("\n\n") init_project( "ports-demo", template="python-starter", @@ -308,7 +316,7 @@ def test_init_install_dependencies_prompt_no(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("desc\nauthor\nn\n") + sys.stdin = _TtyInput("desc\nauthor\nn\n") with patch("nitrostack.cli.main.install_dependencies") as pip_install: init_project("no-npm", template="python-starter") pip_install.assert_not_called() @@ -365,12 +373,25 @@ def test_run_npm_does_not_use_shell(): print("Success! npm is invoked as an argv list without shell=True.") +def test_prompts_return_defaults_without_tty(): + """Studio and CI inherit a non-TTY stdin: prompts must not read from it.""" + original = sys.stdin + try: + sys.stdin = io.StringIO("typed\n") + assert _prompt("Name", "fallback") == "fallback" + assert _prompt_template() == "python-starter" + assert _prompt_yes_no("Install", default_yes=False) is False + assert sys.stdin.read() == "typed\n" + print("Success! prompts use defaults and leave a non-TTY stdin unread.") + finally: + sys.stdin = original + def test_prompt_uses_value_or_default(): original = sys.stdin try: - sys.stdin = io.StringIO("custom\n") + sys.stdin = _TtyInput("custom\n") assert _prompt("Name", "fallback") == "custom" - sys.stdin = io.StringIO("\n") + sys.stdin = _TtyInput("\n") assert _prompt("Name", "fallback") == "fallback" class Boom: @@ -394,13 +415,13 @@ def test_resolve_template_accepts_official_names(): def test_prompt_template_choice_and_explicit_name(): original = sys.stdin try: - sys.stdin = io.StringIO("2\n") + sys.stdin = _TtyInput("2\n") assert _prompt_template() == "python-pizzaz" - sys.stdin = io.StringIO("python-oauth\n") + sys.stdin = _TtyInput("python-oauth\n") assert _prompt_template() == "python-oauth" - sys.stdin = io.StringIO("nope\n3\n") + sys.stdin = _TtyInput("nope\n3\n") assert _prompt_template() == "python-oauth" - sys.stdin = io.StringIO("\n") + sys.stdin = _TtyInput("\n") assert _prompt_template() == "python-starter" print("Success! _prompt_template handles numbers, names, retries, and default.") finally: @@ -458,19 +479,19 @@ def test_upsert_env_var_replaces_and_appends(): def test_prompt_yes_no_answers(): original = sys.stdin try: - sys.stdin = io.StringIO("\n") + sys.stdin = _TtyInput("\n") assert _prompt_yes_no("Install", default_yes=True) is True - sys.stdin = io.StringIO("\n") + sys.stdin = _TtyInput("\n") assert _prompt_yes_no("Install", default_yes=False) is False - sys.stdin = io.StringIO("y\n") + sys.stdin = _TtyInput("y\n") assert _prompt_yes_no("Install", default_yes=False) is True - sys.stdin = io.StringIO("yes\n") + sys.stdin = _TtyInput("yes\n") assert _prompt_yes_no("Install") is True - sys.stdin = io.StringIO("n\n") + sys.stdin = _TtyInput("n\n") assert _prompt_yes_no("Install") is False - sys.stdin = io.StringIO("no\n") + sys.stdin = _TtyInput("no\n") assert _prompt_yes_no("Install") is False - sys.stdin = io.StringIO("maybe\n") + sys.stdin = _TtyInput("maybe\n") assert _prompt_yes_no("Install", default_yes=True) is True print("Success! _prompt_yes_no handles Y/n, empty default, and unknown input.") finally: @@ -543,7 +564,7 @@ def test_init_project_rejects_blank_name_and_overwrite_cancel(): except SystemExit: pass os.mkdir("taken") - sys.stdin = io.StringIO("n\n") + sys.stdin = _TtyInput("n\n") try: init_project("taken", template="python-starter", skip_install=True) raise AssertionError("overwrite cancel should exit") @@ -567,14 +588,14 @@ def test_init_project_overwrite_and_install_yes_calls_npm(): os.chdir(tmp) os.mkdir("taken") open(os.path.join("taken", "old.txt"), "w", encoding="utf-8").write("x") - sys.stdin = io.StringIO("y\ndesc\nauthor\n") + sys.stdin = _TtyInput("y\ndesc\nauthor\n") with patch("nitrostack.cli.main._run_npm") as npm: init_project("taken", template="python-starter", skip_install=True) npm.assert_not_called() assert os.path.isfile(os.path.join("taken", "main.py")) assert not os.path.exists(os.path.join("taken", "old.txt")) - sys.stdin = io.StringIO("d\na\nY\n") + sys.stdin = _TtyInput("d\na\nY\n") with patch("nitrostack.cli.main.install_dependencies") as pip_install: with patch("nitrostack.cli.main._run_npm") as npm: init_project("installed", template="python-starter") @@ -583,7 +604,7 @@ def test_init_project_overwrite_and_install_yes_calls_npm(): npm.assert_not_called() assert os.path.isfile(os.path.join("installed", "widgets", "out", "calculator-result.html")) - sys.stdin = io.StringIO("d\na\n\n") + sys.stdin = _TtyInput("d\na\n\n") with patch("nitrostack.cli.main.install_dependencies") as pip_install: init_project("installed-enter", template="python-starter") pip_install.assert_called_once() @@ -931,7 +952,7 @@ def test_init_rewrites_pyproject_identity_and_copies_uv_files(template): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("Rewritten description\nTester\n") + sys.stdin = _TtyInput("Rewritten description\nTester\n") init_project("My_App", template=template, skip_install=True) project = os.path.join(tmp, "My_App") pyproject = open(os.path.join(project, "pyproject.toml"), encoding="utf-8").read() @@ -960,7 +981,7 @@ def test_init_runs_uv_lock_in_project_dir(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("\n\n") + sys.stdin = _TtyInput("\n\n") with patch.dict(os.environ, {}, clear=False): os.environ.pop("NITROSTACK_SKIP_UV_LOCK", None) with patch("nitrostack.cli.install._uv_bin", return_value="/usr/bin/uv"): @@ -982,7 +1003,7 @@ def test_init_succeeds_when_uv_is_missing(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("\n\n") + sys.stdin = _TtyInput("\n\n") with patch.dict(os.environ, {}, clear=False): os.environ.pop("NITROSTACK_SKIP_UV_LOCK", None) with patch("nitrostack.cli.install._uv_bin", return_value=None): @@ -1001,7 +1022,7 @@ def test_init_succeeds_when_uv_lock_fails(): original_stdin = sys.stdin try: os.chdir(tmp) - sys.stdin = io.StringIO("\n\n") + sys.stdin = _TtyInput("\n\n") with patch.dict(os.environ, {}, clear=False): os.environ.pop("NITROSTACK_SKIP_UV_LOCK", None) with patch("nitrostack.cli.install._uv_bin", return_value="/usr/bin/uv"): diff --git a/tests/test_cli_studio_noninteractive.py b/tests/test_cli_studio_noninteractive.py new file mode 100644 index 0000000..31dfc1b --- /dev/null +++ b/tests/test_cli_studio_noninteractive.py @@ -0,0 +1,120 @@ +"""Studio generate-wizard contract: init must not block on inherited stdin. + +NitroStudio step 4 of 5 used to hang because `nitrostack-py init --skip-install` +still prompted Description/Author via readline() on an inherited stdin that +never wrote. --skip-install only skips dependency install. + +These tests spawn the real CLI with stdin left open (PIPE, never written, never +closed) so they fail if prompts return. +""" +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +def _cli_env(): + env = os.environ.copy() + env["PYTHONPATH"] = ROOT + env["PYTHONUNBUFFERED"] = "1" + return env + + +def _run_init_open_stdin(cwd: str, extra_args: list[str], timeout: float = 20.0): + """Spawn init with stdin PIPE left open. Do not write or close it.""" + proc = subprocess.Popen( + [sys.executable, "-m", "nitrostack.cli.main", *extra_args], + cwd=cwd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=_cli_env(), + ) + chunks_out: list[bytes] = [] + chunks_err: list[bytes] = [] + + def _drain(stream, bucket): + try: + while True: + data = stream.read(4096) + if not data: + break + bucket.append(data) + except Exception: + pass + + threading.Thread(target=_drain, args=(proc.stdout, chunks_out), daemon=True).start() + threading.Thread(target=_drain, args=(proc.stderr, chunks_err), daemon=True).start() + + deadline = time.time() + timeout + while proc.poll() is None: + if time.time() > deadline: + proc.kill() + proc.wait(timeout=5) + stdout = b"".join(chunks_out).decode("utf-8", "replace") + stderr = b"".join(chunks_err).decode("utf-8", "replace") + raise TimeoutError( + f"CLI hung for {timeout}s on open unused stdin\n" + f"stdout:\n{stdout}\nstderr:\n{stderr}" + ) + time.sleep(0.05) + + stdout = b"".join(chunks_out).decode("utf-8", "replace") + stderr = b"".join(chunks_err).decode("utf-8", "replace") + return proc.returncode, stdout, stderr + + +def test_init_skip_install_does_not_hang_on_open_unused_stdin(): + tmp = tempfile.mkdtemp(prefix="nitro-cli-studio-") + try: + code, stdout, stderr = _run_init_open_stdin( + tmp, + ["init", "studio_demo", "--template", "python-starter", "--skip-install"], + ) + assert code == 0, f"exit {code}\n{stdout}\n{stderr}" + project = Path(tmp) / "studio_demo" + assert (project / "main.py").is_file() + assert (project / "app_module.py").is_file() + req = (project / "requirements.txt").read_text(encoding="utf-8") + assert "nitrostack" in req.lower() + finally: + import shutil + + shutil.rmtree(tmp, ignore_errors=True) + + +def test_init_without_args_uses_defaults_on_open_unused_stdin(): + tmp = tempfile.mkdtemp(prefix="nitro-cli-noargs-") + try: + code, stdout, stderr = _run_init_open_stdin(tmp, ["init", "--skip-install"]) + assert code == 0, f"exit {code}\n{stdout}\n{stderr}" + project = Path(tmp) / "my-mcp-server" + assert (project / "main.py").is_file() + finally: + import shutil + + shutil.rmtree(tmp, ignore_errors=True) + +def test_init_existing_dir_exits_without_overwrite_prompt(): + tmp = tempfile.mkdtemp(prefix="nitro-cli-exists-") + try: + os.mkdir(os.path.join(tmp, "already")) + code, stdout, stderr = _run_init_open_stdin( + tmp, + ["init", "already", "--template", "python-starter", "--skip-install"], + timeout=15.0, + ) + assert code != 0 + combined = (stdout + stderr).lower() + assert "already exists" in combined + finally: + import shutil + + shutil.rmtree(tmp, ignore_errors=True) diff --git a/tests/test_project_templates.py b/tests/test_project_templates.py index 35be728..2c3f1a1 100644 --- a/tests/test_project_templates.py +++ b/tests/test_project_templates.py @@ -36,7 +36,9 @@ def run(args, cwd, *, python=None, installed=False): @pytest.mark.parametrize("choice,expected", [("4", "python-enterprise-search"), ("5", "python-code-mode"), ("python-enterprise-search", "python-enterprise-search"), ("python-code-mode", "python-code-mode")]) def test_new_template_prompt_choices(choice, expected, monkeypatch): - monkeypatch.setattr(sys, "stdin", io.StringIO(choice + "\n")) + stdin = io.StringIO(choice + "\n") + stdin.isatty = lambda: True # prompts read only from a TTY + monkeypatch.setattr(sys, "stdin", stdin) assert _prompt_template() == expected assert OFFICIAL_TEMPLATES[expected] in {"enterprise-search", "code-mode"}