diff --git a/openadapt/cli.py b/openadapt/cli.py index dd582068f..847d40357 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -25,6 +25,8 @@ openadapt doctor """ +import platform +import re import sys from pathlib import Path from typing import Optional @@ -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). diff --git a/platform-manifest.json b/platform-manifest.json index 551d47531..a1d3e7c2d 100644 --- a/platform-manifest.json +++ b/platform-manifest.json @@ -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": { @@ -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" } ] }, diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 25597a5af..4f90aa74b 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -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()