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
173 changes: 173 additions & 0 deletions openadapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
openadapt doctor
"""

import platform
import re
import sys
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -259,6 +261,177 @@ def quickstart(out: Path, headed: bool, break_it: bool) -> None:
click.echo("Qualify a consequential workflow: https://openadapt.ai/qualify")


_SECRET_REFERENCE = re.compile(r"^(?:env:[A-Z][A-Z0-9_]*|keychain:[^/\s]+/[^/\s]+)$")
_SUPPORTED_FLOW_RANGE = ">=1.29.0,<2.0.0"


def _supported_flow_version(value: str) -> bool:
"""Return whether a stable Flow version is in the launcher's supported range."""
match = re.fullmatch(
r"(\d+)\.(\d+)\.(\d+)(?:\.post\d+)?(?:\+[a-zA-Z0-9.-]+)?", value
)
if match is None:
return False
parsed = tuple(int(part) for part in match.groups())
return (1, 29, 0) <= parsed < (2, 0, 0)


@main.command("deploy")
@click.option(
"--backend",
type=click.Choice(["web", "windows", "macos", "linux", "rdp", "citrix"]),
default="web",
show_default=True,
help="The execution surface that this customer deployment will use.",
)
@click.option(
"--secret-ref",
multiple=True,
help="Secret reference only: env:NAME or keychain:service/item. Never pass a secret value.",
)
def deploy(backend: str, secret_ref: tuple[str, ...]) -> None:
"""Check this host and print the safe Flow/Desktop deployment path.

This launcher command does not create another runtime or service manager.
It records no secret value, starts no connector, and does not treat an
incomplete preflight as a healthy deployment. It composes the installed
Flow connector, operator console, and repair lifecycle instead.
"""
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as dist_version
from importlib.util import find_spec

click.echo("OpenAdapt deployment preflight")
click.echo("=" * 30)
click.echo("Environment fingerprint:")
click.echo(f" platform: {platform.system()} {platform.release()}")
click.echo(f" machine: {platform.machine() or 'unknown'}")
click.echo(f" python: {platform.python_version()}")
try:
flow_version = dist_version("openadapt-flow")
except PackageNotFoundError:
flow_version = "not installed"
click.echo(f" openadapt-flow: {flow_version}")
click.echo(f" requested backend: {backend}")

bad_refs = [value for value in secret_ref if not _SECRET_REFERENCE.fullmatch(value)]
click.echo("\nSecret references:")
if not secret_ref:
click.echo(" [--] none supplied (valid for a local-only preflight)")
for value in secret_ref:
if value in bad_refs:
click.echo(" [INVALID] rejected secret reference (value hidden)")
else:
click.echo(f" [OK] {value}")
if bad_refs:
raise click.UsageError(
"Secret references must use env:NAME or keychain:service/item; "
"do not pass secret values."
)

click.echo("\nHealth checks:")
failures = []
flow_importable = find_spec("openadapt_flow") is not None
flow_version_ready = flow_version != "not installed" and _supported_flow_version(
flow_version
)
if flow_importable and flow_version_ready:
click.echo(
" [OK] canonical openadapt-flow engine is installed at a supported version"
)
elif not flow_importable or flow_version == "not installed":
failures.append("flow")
click.echo(" [MISSING] canonical openadapt-flow engine is not installed")
else:
failures.append("flow-version")
click.echo(
f" [UNSUPPORTED] openadapt-flow {flow_version}; this launcher "
f"requires {_SUPPORTED_FLOW_RANGE}"
)

if backend == "web":
browser_ready = find_spec("playwright") is not None
if browser_ready:
click.echo(" [OK] Playwright is installed for the web backend")
else:
failures.append("browser")
click.echo(
" [SETUP] install the web extra before recording or replay: "
"python -m pip install 'openadapt[browser]'"
)
else:
click.echo(
f" [SETUP] {backend} readiness is checked by Flow when the "
"configured target opens; this guide does not claim it is ready."
)

console_ready = all(
find_spec(name) is not None
for name in ("fastapi", "uvicorn", "openadapt_types")
)
if console_ready:
click.echo(" [OK] optional local operator console is installed")
else:
click.echo(" [--] optional local operator console is not installed")

if failures:
raise click.ClickException(
"Preflight failed. Resolve every [MISSING], [UNSUPPORTED], and "
"[SETUP] item above before service setup."
)

click.echo(
"\nPreflight passed for the installed components. No service was started."
)
click.echo("\nGuided deployment path:")
click.echo(
" 1. Re-run full host diagnostics: openadapt doctor --backend " + backend
)
click.echo(" 2. Open the authenticated Cloud connector settings:")
click.echo(" https://app.openadapt.ai/dashboard/settings/connectors")
click.echo(
" Create the customer-local connector there and put the issued "
"references in its environment or OS keychain."
)
click.echo(
" 3. Start one governed poll only after authenticated setup: "
"openadapt flow connector run --profile deployment.yaml --once"
)
if console_ready:
click.echo(
" 4. Inspect local health, reports, and halt evidence: "
"openadapt flow console --bundles bundles --runs runs"
)
click.echo(
" The console is loopback-only and read-only unless you explicitly "
"enable its governed actions."
)
else:
click.echo(" 4. Optional local console setup:")
click.echo(
f" python -m pip install 'openadapt-flow[console]=={flow_version}'"
)
click.echo(
" Re-run this preflight after installation before you start the console."
)
click.echo(
" 5. In OpenAdapt Desktop, use the signed-in workspace connection and "
"open the matching run report; do not use the Desktop view as proof of effect."
)
click.echo(
" 6. Upgrade the launcher and its pinned Flow dependency: "
"python -m pip install --upgrade openadapt"
)
click.echo(
" 7. Roll back a governed bundle, not an unverified package: "
"openadapt flow repair rollback --store repair-store"
)
click.echo(
" 8. Uninstall only after retaining required run evidence: "
"python -m pip uninstall openadapt openadapt-flow"
)


@main.group(cls=_FlowPassthroughGroup)
def flow():
"""Record, compile, and replay workflows (the demonstration compiler).
Expand Down
16 changes: 8 additions & 8 deletions platform-manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_kind": "openadapt-platform-release-manifest",
"schema_version": "1.0.0",
"generated_at": "2026-08-02T20:05:53+00:00",
"generated_at": "2026-08-05T22:51:33+00:00",
"release_channel": "beta",
"components": {
"launcher": {
Expand All @@ -26,21 +26,21 @@
},
"flow": {
"package": "openadapt-flow",
"version": "1.29.0",
"version": "1.30.0",
"source": "pypi",
"requires_python": "<3.13,>=3.10",
"artifacts": [
{
"type": "bdist_wheel",
"filename": "openadapt_flow-1.29.0-py3-none-any.whl",
"url": "https://files.pythonhosted.org/packages/8f/ab/f05f65adb368a6135a32eb201e341e00e5ccc7334715d4381aad2e656c3a/openadapt_flow-1.29.0-py3-none-any.whl",
"sha256": "2c5f49199161fb2964bb43cfae2470fb79e5dbcb264e3031d6f8802ecb161a6a"
"filename": "openadapt_flow-1.30.0-py3-none-any.whl",
"url": "https://files.pythonhosted.org/packages/5c/0e/680aebe9683c358d1f4b9d0aac62c21f6698f63c46dbff4e027f9d3836ae/openadapt_flow-1.30.0-py3-none-any.whl",
"sha256": "7bf1a7b00388172a79bda666def182688c296ffb5bc9be2fd3281169fc36ae63"
},
{
"type": "sdist",
"filename": "openadapt_flow-1.29.0.tar.gz",
"url": "https://files.pythonhosted.org/packages/a1/4f/53d31b2a8600a61e35ab9aa469e25591e327eb2301039c5e55f788044a82/openadapt_flow-1.29.0.tar.gz",
"sha256": "d4ba0fb0af915d8fbf642fdf0843524f04992e8b3080febfb618febe507311a7"
"filename": "openadapt_flow-1.30.0.tar.gz",
"url": "https://files.pythonhosted.org/packages/c8/5d/883e608ac2a8e414551b55368fdc3b0b52a83f58ccaff8ba47956e22f5be/openadapt_flow-1.30.0.tar.gz",
"sha256": "3a402610e35f47fd54daaf066b8c3a6006c13483cb4c778740014abea1854ea4"
}
]
},
Expand Down
112 changes: 112 additions & 0 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,118 @@ def test_doctor_does_not_require_browser_for_citrix(monkeypatch):
assert "no Playwright or Chromium setup will run" in result.output


def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets(
monkeypatch,
):
"""A clean host gets diagnostics plus commands for the existing engine.

The deployment guide must remain a launcher seam: it does not start a
second engine, accept a secret value, or replace Flow's rollback path.
"""
monkeypatch.setattr(
"importlib.util.find_spec",
lambda name: (
object()
if name in {"openadapt_flow", "fastapi", "uvicorn", "openadapt_types"}
else None
),
)
monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0")
result = CliRunner().invoke(
cli_main,
["deploy", "--backend", "rdp", "--secret-ref", "env:BYOC_CONNECTOR_TOKEN"],
)

assert result.exit_code == 0, result.output
assert "Environment fingerprint" in result.output
assert "env:BYOC_CONNECTOR_TOKEN" in result.output
assert "dashboard/settings/connectors" in result.output
assert "connector enroll" not in result.output
assert "connector run" in result.output
assert "flow console" in result.output
assert "flow repair rollback" in result.output
assert "No service was started" in result.output


def test_deploy_base_hosted_install_gives_conditional_console_setup(monkeypatch):
"""Base Flow hosted installs must not receive an unusable console command."""
monkeypatch.setattr(
"importlib.util.find_spec",
lambda name: object() if name == "openadapt_flow" else None,
)
monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0")

result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"])

assert result.exit_code == 0, result.output
assert "optional local operator console is not installed" in result.output
assert "python -m pip install 'openadapt-flow[console]==1.29.0'" in result.output
assert "openadapt flow console --bundles" not in result.output
assert "Re-run this preflight" in result.output


def test_deploy_console_requires_openadapt_types(monkeypatch):
"""Flow 1.30 imports openadapt-types when the operator console starts."""
monkeypatch.setattr(
"importlib.util.find_spec",
lambda name: (
object() if name in {"openadapt_flow", "fastapi", "uvicorn"} else None
),
)
monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.30.0")

result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"])

assert result.exit_code == 0, result.output
assert "optional local operator console is not installed" in result.output
assert "python -m pip install 'openadapt-flow[console]==1.30.0'" in result.output
assert "openadapt flow console --bundles" not in result.output


def test_deploy_preflight_refuses_secret_values_and_missing_engine(monkeypatch):
monkeypatch.setattr("importlib.util.find_spec", lambda _name: None)

runner = CliRunner()
secret_result = runner.invoke(cli_main, ["deploy", "--secret-ref", "actual-secret"])
assert secret_result.exit_code != 0
assert "do not pass secret values" in secret_result.output
assert "actual-secret" not in secret_result.output
assert "value hidden" in secret_result.output

missing_result = runner.invoke(cli_main, ["deploy", "--backend", "windows"])
assert missing_result.exit_code != 0
assert "[MISSING]" in missing_result.output
assert "Preflight failed" in missing_result.output


def test_deploy_preflight_fails_without_web_runtime(monkeypatch):
monkeypatch.setattr(
"importlib.util.find_spec",
lambda name: object() if name == "openadapt_flow" else None,
)
monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0")

result = CliRunner().invoke(cli_main, ["deploy", "--backend", "web"])

assert result.exit_code != 0
assert "[SETUP] install the web extra" in result.output
assert "Preflight failed" in result.output
assert "Preflight passed" not in result.output


@pytest.mark.parametrize("flow_version", ["1.28.9", "2.0.0", "2.1.0", "invalid"])
def test_deploy_preflight_fails_for_unsupported_flow(monkeypatch, flow_version):
monkeypatch.setattr("importlib.util.find_spec", lambda _name: object())
monkeypatch.setattr("importlib.metadata.version", lambda _name: flow_version)

result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"])

assert result.exit_code != 0
assert "[UNSUPPORTED]" in result.output
assert ">=1.29.0,<2.0.0" in result.output
assert "Preflight passed" not in result.output


def test_top_level_help_leads_with_flow():
"""`openadapt --help` must list flow before the other commands."""
runner = CliRunner()
Expand Down