From b81ed86ee829c0b5afe869370cd873dee7d92e4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:28:26 +0000 Subject: [PATCH] Fix onboard never launching the app and `assembly dev` failing on existing .venv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs hit when scaffolding a voice agent through `assembly onboard`: 1. The wizard scaffolded with launch=False (so the blocking dev server wouldn't cut off the remaining sections) but then never launched the app at all. build_path now records the scaffolded directory on the WizardContext, and a new launch_app section — ordered last because the server blocks until Ctrl-C — offers to start the dev server and open the browser, with a `cd && assembly dev` hint when declined or non-interactive. init's `_launch` is made public as `launch_app` so the wizard reuses the same launch path. 2. `assembly dev` inside a scaffolded project failed with "A virtual environment already exists at `.venv`. Use `--clear` to replace it": init had already created .venv, and current uv refuses to re-create it. The uv setup path now runs `uv venv --allow-existing` so re-running setup reuses the venv (the stdlib `python -m venv` path already did). https://claude.ai/code/session_01WGxwhWEjerUKMUKXXXatES --- aai_cli/commands/init.py | 13 +++-- aai_cli/init/runner.py | 13 ++++- aai_cli/onboard/sections.py | 40 ++++++++++++++- aai_cli/onboard/wizard.py | 2 + tests/test_init_runner.py | 7 ++- tests/test_onboard_sections.py | 93 +++++++++++++++++++++++++++++++++- tests/test_onboard_wizard.py | 24 ++++++++- 7 files changed, 180 insertions(+), 12 deletions(-) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index 05cb22b9..6d262e6e 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -152,8 +152,12 @@ def _scaffold_report(chosen: str, target: Path, api_key: str | None) -> list[ste return report -def _launch(target: Path, *, port: int, use_uv: bool, no_open: bool, json_mode: bool) -> None: - """Start the scaffolded app on a free port and open the browser, then block.""" +def launch_app(target: Path, *, port: int, use_uv: bool, no_open: bool, json_mode: bool) -> None: + """Start the scaffolded app on a free port and open the browser, then block. + + Public (not underscore-private) because the onboarding wizard launches the + scaffolded app as its final step, after the remaining wizard sections have run. + """ chosen_port = runner.find_free_port(port) url = f"http://localhost:{chosen_port}" if not json_mode: @@ -182,7 +186,8 @@ def run_init( """Scaffold (and optionally install/launch) a template; return the target dir. `launch=False` is for callers like the onboarding wizard that must not block on a - running dev server — it stops after install and leaves the run command as a hint. + running dev server mid-flow — it stops after install and leaves the run command as + a hint (the wizard calls `launch_app` itself once its remaining sections are done). """ if not json_mode: # Vercel-style banner at the top of the run. Decoration goes to stderr (data → @@ -218,7 +223,7 @@ def run_init( raise typer.Exit(code=1) if launch and will_launch: - _launch(target, port=port, use_uv=use_uv, no_open=no_open, json_mode=json_mode) + launch_app(target, port=port, use_uv=use_uv, no_open=no_open, json_mode=json_mode) elif not json_mode: # Scaffolded but not launched (no key, or --no-install, or launch=False): leave the # user with the one command that starts their app, the way `vercel`/`supabase` sign off. diff --git a/aai_cli/init/runner.py b/aai_cli/init/runner.py index e0606225..274657ca 100644 --- a/aai_cli/init/runner.py +++ b/aai_cli/init/runner.py @@ -24,9 +24,18 @@ def venv_python(target: Path) -> Path: def env_setup_commands(target: Path, *, use_uv: bool) -> list[list[str]]: - """Commands (run with cwd=target) to create a venv and install requirements.""" + """Commands (run with cwd=target) to create a venv and install requirements. + + `--allow-existing` keeps the uv path idempotent: `assembly init` creates `.venv`, + and a later `assembly dev` runs this setup again — without the flag, uv refuses + with "A virtual environment already exists" instead of reusing it (the stdlib + `python -m venv` path already reuses an existing venv). + """ if use_uv: - return [["uv", "venv"], ["uv", "pip", "install", "-r", "requirements.txt"]] + return [ + ["uv", "venv", "--allow-existing"], + ["uv", "pip", "install", "-r", "requirements.txt"], + ] py = str(venv_python(target)) return [ [sys.executable, "-m", "venv", ".venv"], diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index 69736202..83bbe5af 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from enum import Enum +from pathlib import Path import assemblyai as aai import typer @@ -12,6 +13,7 @@ from aai_cli.commands import setup as setup_cmd from aai_cli.context import AppState, persist_browser_login from aai_cli.errors import CLIError +from aai_cli.init import runner from aai_cli.onboard.prompter import Prompter @@ -26,6 +28,9 @@ class WizardContext: state: AppState profile: str json_mode: bool + # Set by build_path when a template is scaffolded; launch_app (the wizard's last + # section) reads it to start the dev server once the other sections are done. + scaffolded: Path | None = None def _has_key(profile: str) -> bool: @@ -116,9 +121,10 @@ def build_path(prompter: Prompter, ctx: WizardContext) -> SectionResult: if not prompter.confirm(f"Scaffold the '{choice}' app now?", default=True): prompter.note(f"You can run `assembly init {choice}` whenever you're ready.") return SectionResult.SKIPPED - # launch=False: never block the wizard on a running dev server. + # launch=False: the dev server blocks until Ctrl-C, so launching here would stop + # the remaining sections from running. launch_app starts it once the wizard is done. try: - init_cmd.run_init( + ctx.scaffolded = init_cmd.run_init( ctx.state, template=choice, directory=None, @@ -157,3 +163,33 @@ def next_steps(prompter: Prompter, _ctx: WizardContext) -> SectionResult: output.console.print(output.hint("Stream live audio: assembly stream")) output.console.print(output.hint("Build an app: assembly init")) return SectionResult.DONE + + +def launch_app(prompter: Prompter, ctx: WizardContext) -> SectionResult: + """Start the dev server for the app build_path scaffolded, like `assembly init` does. + + Must run as the wizard's final section: the server blocks until Ctrl-C, so any + section after it would never run. + """ + if ctx.scaffolded is None: + return SectionResult.SKIPPED + run_hint = f"cd {ctx.scaffolded} && assembly dev" + if not prompter.interactive: + prompter.note(f"Launch your app with `{run_hint}`.") + return SectionResult.SKIPPED + prompter.section("Launch your app") + if not prompter.confirm("Start the dev server and open the browser now?", default=True): + prompter.note(f"Launch it any time with `{run_hint}`.") + return SectionResult.SKIPPED + try: + init_cmd.launch_app( + ctx.scaffolded, + port=3000, + use_uv=runner.has_uv(), + no_open=False, + json_mode=ctx.json_mode, + ) + except (CLIError, typer.Exit): + output.error_console.print(output.fail(f"The dev server didn't start. Try `{run_hint}`.")) + return SectionResult.FAILED + return SectionResult.DONE diff --git a/aai_cli/onboard/wizard.py b/aai_cli/onboard/wizard.py index d929bc07..f4b57d86 100644 --- a/aai_cli/onboard/wizard.py +++ b/aai_cli/onboard/wizard.py @@ -26,6 +26,8 @@ def run_onboarding(prompter: Prompter, ctx: WizardContext) -> int: sections.build_path(prompter, ctx) sections.claude_code(prompter, ctx) sections.next_steps(prompter, ctx) + # Last on purpose: the dev server blocks until Ctrl-C. + sections.launch_app(prompter, ctx) except WizardCancelled: output.error_console.print( output.hint("Setup cancelled. Run `assembly onboard` to resume.") diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index 4823504f..5eee5b8d 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -25,8 +25,13 @@ def test_venv_python_path_per_platform(monkeypatch): def test_env_setup_commands_uv(): + # --allow-existing keeps re-running setup idempotent: `assembly init` creates .venv, + # and `assembly dev` in that project must reuse it, not fail on "already exists". cmds = runner.env_setup_commands(Path("/proj"), use_uv=True) - assert cmds == [["uv", "venv"], ["uv", "pip", "install", "-r", "requirements.txt"]] + assert cmds == [ + ["uv", "venv", "--allow-existing"], + ["uv", "pip", "install", "-r", "requirements.txt"], + ] def test_env_setup_commands_venv(): diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index 8f0643f9..8d12025c 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -11,6 +11,7 @@ from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd from aai_cli.context import AppState +from aai_cli.errors import CLIError from aai_cli.onboard import sections from aai_cli.onboard.prompter import NonInteractivePrompter from aai_cli.onboard.sections import SectionResult, WizardContext @@ -35,12 +36,13 @@ def __init__(self, *, select: str = "skip", confirm: bool = True, text: str = "k self._text = text self.confirm_defaults: list[bool] = [] self.text_titles: list[str] = [] + self.notes: list[str] = [] def section(self, title: str) -> None: pass def note(self, message: str) -> None: - pass + self.notes.append(message) def confirm(self, title: str, *, default: bool = True) -> bool: self.confirm_defaults.append(default) @@ -216,13 +218,15 @@ def _fake_run_init(*a: object, **k: object) -> Path: nonlocal calls calls += 1 seen.update(k) - return Path() + return Path("/scaffolded/app") monkeypatch.setattr(init_cmd, "run_init", _fake_run_init) prompter = _ScriptedPrompter(select="audio-transcription", confirm=True) result = sections.build_path(prompter, ctx) assert result is SectionResult.DONE assert calls == 1 + # The scaffold target is recorded so launch_app can start it at the end of the wizard. + assert ctx.scaffolded == Path("/scaffolded/app") # The scaffold confirmation defaults to Yes (a False mutant would change the prompt). assert prompter.confirm_defaults == [True] # Pin the exact run_init kwargs the wizard relies on (each is a mutated literal): @@ -261,6 +265,8 @@ def _boom(*a: object, **k: object) -> Path: monkeypatch.setattr(init_cmd, "run_init", _boom) result = sections.build_path(_ScriptedPrompter(select="live-captions", confirm=True), ctx) assert result is SectionResult.FAILED + # Nothing scaffolded, so launch_app must have nothing to launch. + assert ctx.scaffolded is None def test_claude_code_skipped(ctx: WizardContext) -> None: @@ -302,3 +308,86 @@ def _failing_step(*a: object, **k: object) -> Step: monkeypatch.setattr(setup_cmd, "install_skill", _failing_step) monkeypatch.setattr(setup_cmd, "install_cli_skill", _passing_step) assert sections.claude_code(_ScriptedPrompter(confirm=True), ctx) is SectionResult.FAILED + + +def _spy_launch(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: + """Replace init's launch_app with a recorder; returns the captured target + kwargs.""" + captured: dict[str, object] = {} + + def _fake_launch(target: Path, **k: object) -> None: + captured["target"] = target + captured.update(k) + + monkeypatch.setattr(init_cmd, "launch_app", _fake_launch) + return captured + + +def test_launch_app_skipped_when_nothing_scaffolded( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + captured = _spy_launch(monkeypatch) + assert sections.launch_app(_ScriptedPrompter(confirm=True), ctx) is SectionResult.SKIPPED + assert captured == {} + + +def test_launch_app_noninteractive_hints_instead_of_blocking( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + # A headless run must never start a blocking dev server; it leaves the run command. + captured = _spy_launch(monkeypatch) + ctx.scaffolded = Path("/scaffolded/app") + + class _RecordingNonInteractive(NonInteractivePrompter): + def __init__(self) -> None: + self.notes: list[str] = [] + + def note(self, message: str) -> None: + self.notes.append(message) + + prompter = _RecordingNonInteractive() + assert sections.launch_app(prompter, ctx) is SectionResult.SKIPPED + assert captured == {} + assert any("cd /scaffolded/app && assembly dev" in note for note in prompter.notes) + + +def test_launch_app_declined_leaves_hint( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + captured = _spy_launch(monkeypatch) + ctx.scaffolded = Path("/scaffolded/app") + prompter = _ScriptedPrompter(confirm=False) + assert sections.launch_app(prompter, ctx) is SectionResult.SKIPPED + assert captured == {} + assert any("cd /scaffolded/app && assembly dev" in note for note in prompter.notes) + + +@pytest.mark.parametrize("uv", [True, False]) +def test_launch_app_launches_scaffolded_app( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch, uv: bool +) -> None: + captured = _spy_launch(monkeypatch) + monkeypatch.setattr("aai_cli.init.runner.has_uv", lambda: uv) + ctx.scaffolded = Path("/scaffolded/app") + prompter = _ScriptedPrompter(confirm=True) + assert sections.launch_app(prompter, ctx) is SectionResult.DONE + # Launching defaults to Yes (an Enter keypress starts the server). + assert prompter.confirm_defaults == [True] + # Pin the exact launch kwargs (each is a mutable literal): the default port, the + # detected runner, a browser that actually opens, and the wizard's human output mode. + assert captured["target"] == Path("/scaffolded/app") + assert captured["port"] == 3000 + assert captured["use_uv"] is uv + assert captured["no_open"] is False + assert captured["json_mode"] is False + + +@pytest.mark.parametrize("exc", [typer.Exit(code=1), CLIError("boom")]) +def test_launch_app_failure( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch, exc: Exception +) -> None: + def _boom(*a: object, **k: object) -> None: + raise exc + + monkeypatch.setattr(init_cmd, "launch_app", _boom) + ctx.scaffolded = Path("/scaffolded/app") + assert sections.launch_app(_ScriptedPrompter(confirm=True), ctx) is SectionResult.FAILED diff --git a/tests/test_onboard_wizard.py b/tests/test_onboard_wizard.py index 023f8b75..101e77c8 100644 --- a/tests/test_onboard_wizard.py +++ b/tests/test_onboard_wizard.py @@ -36,6 +36,15 @@ def _first(p: object, c: object) -> SectionResult: def test_happy_path_runs_all_sections(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[str] = [] + + def _record(name: str): + def _section(p: object, c: object) -> SectionResult: + ran.append(name) + return SectionResult.DONE + + return _section + for name in ( "welcome", "auth", @@ -44,9 +53,22 @@ def test_happy_path_runs_all_sections(ctx: WizardContext, monkeypatch: pytest.Mo "build_path", "claude_code", "next_steps", + "launch_app", ): - monkeypatch.setattr(sections, name, lambda p, c: SectionResult.DONE) + monkeypatch.setattr(sections, name, _record(name)) assert wizard.run_onboarding(NonInteractivePrompter(), ctx) == 0 + # launch_app must run, and run last: its dev server blocks until Ctrl-C, so any + # section ordered after it would never execute. + assert ran == [ + "welcome", + "auth", + "first_request", + "environment", + "build_path", + "claude_code", + "next_steps", + "launch_app", + ] def test_cancel_returns_130(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: