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
13 changes: 9 additions & 4 deletions aai_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 →
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions aai_cli/init/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
40 changes: 38 additions & 2 deletions aai_cli/onboard/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from dataclasses import dataclass
from enum import Enum
from pathlib import Path

import assemblyai as aai
import typer
Expand All @@ -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


Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions aai_cli/onboard/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
7 changes: 6 additions & 1 deletion tests/test_init_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
93 changes: 91 additions & 2 deletions tests/test_onboard_sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
24 changes: 23 additions & 1 deletion tests/test_onboard_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down
Loading