Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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")

Expand Down
75 changes: 48 additions & 27 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
Expand All @@ -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.")
Expand All @@ -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()
Expand All @@ -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"))
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"):
Expand All @@ -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):
Expand All @@ -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"):
Expand Down
120 changes: 120 additions & 0 deletions tests/test_cli_studio_noninteractive.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion tests/test_project_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
Loading