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
5 changes: 5 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@ source_modules =
aai_cli.config_builder
aai_cli.context
aai_cli.debuglog
aai_cli.deploy_exec
aai_cli.dev_exec
aai_cli.dictate_exec
aai_cli.dub_exec
aai_cli.environments
aai_cli.errors
aai_cli.eval_data
aai_cli.evaluate_exec
aai_cli.follow
aai_cli.help_panels
aai_cli.help_text
aai_cli.hotkey
aai_cli.init
aai_cli.init_exec
aai_cli.llm
aai_cli.llm_exec
aai_cli.microphone
Expand All @@ -37,6 +41,7 @@ source_modules =
aai_cli.procs
aai_cli.remotefs
aai_cli.render
aai_cli.share_exec
aai_cli.speak_exec
aai_cli.stdio
aai_cli.stream_exec
Expand Down
136 changes: 10 additions & 126 deletions aai_cli/commands/deploy.py
Original file line number Diff line number Diff line change
@@ -1,132 +1,16 @@
# aai_cli/commands/deploy.py
from __future__ import annotations

import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

import typer

from aai_cli import help_panels, options, output
from aai_cli.context import AppState, run_command
from aai_cli.errors import CLIError, UsageError
from aai_cli import deploy_exec, help_panels, options
from aai_cli.context import run_command
from aai_cli.help_text import examples_epilog
from aai_cli.init import procfile

# Flattened single-command sub-typer (same pattern as `assembly dev`).
app = typer.Typer()


@dataclass(frozen=True)
class Target:
name: str # human label, e.g. "Vercel"
bin: str # executable resolved via shutil.which
flag: str # CLI selector, e.g. "--vercel"
install: str # hint sentence shown when the CLI is missing (everywhere, or macOS-only)
deploy_args: tuple[str, ...] # subcommand(s) appended after `bin`
supports_prod: bool = False # whether `--prod` adds a production flag
post_deploy_args: tuple[str, ...] | None = None # command run after a successful deploy
install_non_darwin: str | None = None # hint off-macOS, when `install` is brew-specific

def command(self, *, prod: bool) -> list[str]:
argv = [self.bin, *self.deploy_args]
if prod and self.supports_prod:
argv.append("--prod")
return argv


VERCEL = Target(
name="Vercel",
bin="vercel",
flag="--vercel",
install="Install it with `npm i -g vercel`.",
deploy_args=("deploy",),
supports_prod=True,
)
RAILWAY = Target(
name="Railway",
bin="railway",
flag="--railway",
install="Install it with `npm i -g @railway/cli`.",
deploy_args=("up",),
post_deploy_args=("domain",),
)
FLY = Target(
name="Fly",
bin="fly",
flag="--fly",
# brew is macOS-specific; elsewhere point at the official install docs.
install="Install it with `brew install flyctl`.",
install_non_darwin="Install it: https://fly.io/docs/flyctl/install/",
# `fly launch` does it all: creates the app, generates fly.toml (detecting the
# shipped Dockerfile), and deploys — so no fly.toml needs to exist beforehand.
deploy_args=("launch",),
)

TARGETS = (VERCEL, RAILWAY, FLY)


def _resolve_target(selected: list[Target]) -> Target:
if len(selected) > 1:
flags = " / ".join(t.flag for t in TARGETS)
raise UsageError(f"Pass at most one deploy target ({flags}).")
return selected[0] if selected else VERCEL # Vercel is the default


def _install_hint(target: Target) -> str:
"""The platform-appropriate install hint: brew on macOS, docs URL elsewhere."""
if target.install_non_darwin is not None and sys.platform != "darwin":
return target.install_non_darwin
return target.install


def _require_cli(target: Target) -> None:
if shutil.which(target.bin) is None:
raise CLIError(
f"The {target.name} CLI is required to deploy. {_install_hint(target)}",
error_type="missing_dependency",
exit_code=1,
)


def _confirmed(target: Target, *, assume_yes: bool) -> bool:
"""True when the deploy should proceed: --yes, or an interactive yes.

Refuses to guess in a non-interactive/agent session."""
if assume_yes:
return True
if output.is_agentic():
raise UsageError(
"Refusing to deploy without confirmation in a non-interactive session. "
"Pass --yes to deploy."
)
return typer.confirm(f"Deploy this project to {target.name}?")


def run_deploy(*, target: Target, prod: bool, assume_yes: bool, json_mode: bool) -> None:
"""Confirm, then run the target's deploy command in the current directory."""
if prod and not target.supports_prod:
raise UsageError(
"--prod is only supported for Vercel deploys.",
suggestion=f"Drop --prod, or drop {target.flag} to deploy to Vercel.",
)
# Same not-a-project guard as `assembly dev`/`assembly share`, checked before CLI presence
# so an empty directory says "run `assembly init`", not "install the Vercel CLI".
procfile.require_procfile(Path.cwd())
_require_cli(target)
if not _confirmed(target, assume_yes=assume_yes):
aborted = {"status": "aborted", "target": target.name}
output.emit(aborted, lambda _d: "Aborted.", json_mode=json_mode)
return
result = subprocess.run(target.command(prod=prod), cwd=Path.cwd(), check=False)
if result.returncode:
raise typer.Exit(code=result.returncode)
if target.post_deploy_args is not None:
subprocess.run([target.bin, *target.post_deploy_args], cwd=Path.cwd(), check=False)


@app.command(
rich_help_panel=help_panels.BUILD,
epilog=examples_epilog(
Expand All @@ -153,11 +37,11 @@ def deploy(
`railway up`, or `fly launch`). Requires that target's CLI to be installed.
(Render deploys from a connected Git repo — see the project README.)
"""

def body(_state: AppState, json_mode: bool) -> None:
selected = [t for t, on in ((VERCEL, vercel), (RAILWAY, railway), (FLY, fly)) if on]
run_deploy(
target=_resolve_target(selected), prod=prod, assume_yes=assume_yes, json_mode=json_mode
)

run_command(ctx, body, json=json_out)
opts = deploy_exec.DeployOptions(
prod=prod, vercel=vercel, railway=railway, fly=fly, assume_yes=assume_yes
)
run_command(
ctx,
lambda state, json_mode: deploy_exec.run_deploy(opts, state, json_mode=json_mode),
json=json_out,
)
65 changes: 9 additions & 56 deletions aai_cli/commands/dev.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,18 @@
# aai_cli/commands/dev.py
from __future__ import annotations

import os
from pathlib import Path

import typer
from rich.markup import escape

from aai_cli import help_panels, options, output, steps
from aai_cli.context import AppState, run_command
from aai_cli import dev_exec, help_panels, options
from aai_cli.context import run_command
from aai_cli.help_text import examples_epilog
from aai_cli.init import devserver, procfile, runner
from aai_cli.init import devserver

# Flattened single-command sub-typer (same pattern as `assembly init`): one
# @app.command() registered via app.add_typer(dev.app) with no name.
app = typer.Typer()


def run_dev(
*, port: int, host: str, no_install: bool, no_open: bool, json_mode: bool, quiet: bool
) -> None:
"""Boot the project's Procfile `web:` process locally, with live reload."""
target = Path.cwd()
use_uv = runner.has_uv()

chosen_port = runner.find_free_port(port)
devserver.notify_port_change(port, chosen_port, json_mode=json_mode, quiet=quiet)
env = {**os.environ, "PORT": str(chosen_port)}
# Resolves the start command AND validates we're inside a scaffolded project.
web = procfile.web_argv(target, env=env)

report: list[steps.Step] = [
devserver.install_step(target, no_install=no_install, use_uv=use_uv)
]
output.emit(report, lambda d: steps.render_steps(d, heading="Dev"), json_mode=json_mode)
if any(s["status"] == "failed" for s in report):
raise typer.Exit(code=1)

command = devserver.dev_command(target, web, use_uv=use_uv, host=host)
# The printed URL reflects the actual bind: "localhost" for the loopback
# default, the literal host for an explicit --host.
url_host = "localhost" if host == devserver.LOCAL_HOST else host
url = f"http://{url_host}:{chosen_port}"
if not json_mode:
output.console.print(
f"[aai.heading]Starting[/aai.heading] [aai.url]{escape(url)}[/aai.url]"
" [aai.muted](Ctrl-C to stop)[/aai.muted]"
)
code = runner.run_server(
target, command=command, port=chosen_port, env=env, open_browser=not no_open
)
if code:
raise typer.Exit(code=code)


@app.command(
rich_help_panel=help_panels.BUILD,
epilog=examples_epilog(
Expand Down Expand Up @@ -84,15 +43,9 @@ def dev(
Run this from inside a project created by `assembly init`. It installs dependencies
if needed, then starts the FastAPI server with live reload and opens the browser.
"""

def body(state: AppState, json_mode: bool) -> None:
run_dev(
port=port,
host=host,
no_install=no_install,
no_open=no_open,
json_mode=json_mode,
quiet=state.quiet,
)

run_command(ctx, body, json=json_out)
opts = dev_exec.DevOptions(port=port, host=host, no_install=no_install, no_open=no_open)
run_command(
ctx,
lambda state, json_mode: dev_exec.run_dev(opts, state, json_mode=json_mode),
json=json_out,
)
Loading
Loading