diff --git a/AGENTS.md b/AGENTS.md index f1359931..ac63b348 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ uv run ty check src/ The CLI entry point after install: ```bash -uv run authsome init +uv run authsome onboard uv run authsome login github uv run authsome provider list ``` @@ -144,9 +144,9 @@ vault:::state server::client ``` -**Config** (`GlobalConfig`) is stored in the KV store under `config/global`. Key fields: `active_identity` (the handle of the current identity), `vault_id` (the active vault resolved at `authsome init`). Encryption mode is set via `config.encryption.mode` (`local_key` or `keyring`). +**Config** (`GlobalConfig`) is stored in the KV store under `config/global`. Key fields: `active_identity` (the handle of the current identity), `vault_id` (the active vault resolved at `authsome onboard`). Encryption mode is set via `config.encryption.mode` (`local_key` or `keyring`). -**CLI (`src/authsome/cli/main.py`)** is Click-based. All commands support `--json` for machine-readable output. `authsome init` creates the local identity, registers it with the daemon, and writes `active_identity` to config. +**CLI (`src/authsome/cli/main.py`)** is Click-based. All commands support `--json` for machine-readable output. `authsome onboard` creates the local identity, registers it with the daemon, imports API keys from env, and writes `active_identity` to config. ## Agent skills diff --git a/CONTEXT.md b/CONTEXT.md index cea2ccbf..7bd96152 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -202,7 +202,7 @@ _Avoid_: vault_name, vault_handle **VaultHandle**: Human-readable name for a Vault (e.g., `default`). Used in UIs and CLI; the VaultId is authoritative in storage. -**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created when an authenticated Principal confirms the browser claim that `authsome init` initiates. Vault access is gated until the claim is accepted. +**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created when an authenticated Principal confirms the browser claim that `authsome onboard` initiates. Vault access is gated until the claim is accepted. _Avoid_: Claim, IdentityRegistration (as claim), join request **ClaimStatus**: Lifecycle state: `pending` → `accepted` | `rejected`. @@ -211,7 +211,7 @@ _Avoid_: Claim, IdentityRegistration (as claim), join request ## Initialization & Claim Flow -There is a single flow for every deployment — no deployment mode (see ADR 0007). `authsome init` creates an Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. The user opens the URL and registers (or logs in) with **email + password**: the first Principal created on a server becomes `admin`, all subsequent Principals are `user`. The authenticated Principal then confirms the claim, which binds the Identity to the Principal and creates the Principal's default Vault. Until the claim is `accepted`, all vault operations return `403`. The CLI opens the claim URL automatically and polls for completion (and prints the URL to stderr for headless use). +There is a single flow for every deployment — no deployment mode (see ADR 0007). `authsome onboard` creates an Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. The user opens the URL and registers (or logs in) with **email + password**: the first Principal created on a server becomes `admin`, all subsequent Principals are `user`. The authenticated Principal then confirms the claim, which binds the Identity to the Principal and creates the Principal's default Vault. Until the claim is `accepted`, all vault operations return `403`. The CLI opens the claim URL automatically and polls for completion (and prints the URL to stderr for headless use). --- diff --git a/README.md b/README.md index 84064f31..c62db167 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,18 @@ See the [self-hosting guide](docs/guides/self-hosting.md) for volume backup, TLS ## Quick Start +Install and run first-time setup (identity, claim, and API key import from `.env`): + +```bash +authsome onboard +``` + +For a remote daemon, pass `--base-url` once — it is saved in client config for later commands: + +```bash +authsome onboard --base-url https://authsome.example.com +``` + Add the authsome skill to your agent (claude, codex, cursor, hermes, etc.): ```bash diff --git a/docs/internal/manual-testing.md b/docs/internal/manual-testing.md index 105f7c5b..2e270197 100644 --- a/docs/internal/manual-testing.md +++ b/docs/internal/manual-testing.md @@ -57,7 +57,7 @@ uv run authsome doctor **Expected:** Exit code `0`; JSON `{"v": 1, "status": "ready", "checks": {"spec_version": "ok", "store": "ok", "identity": "ok", "providers": "ok", "connections": "ok", "vault": "ok", "integrity": "ok"}, "issues": [], "warnings": [...]}`. The `warnings` array is non-empty on a fresh install with no connections (e.g. "no active provider connections found"). A non-`ready` status exits `1`. -> **Tip:** `authsome init` performs the same register + claim flow explicitly and prints an `{"status": "initialized", ...}` payload. +> **Tip:** `authsome onboard` performs register + claim and imports API keys from env in one step, printing a combined JSON payload. --- diff --git a/docs/site/concepts/principal-vault-identity.mdx b/docs/site/concepts/principal-vault-identity.mdx index d36c9049..81a8d04e 100644 --- a/docs/site/concepts/principal-vault-identity.mdx +++ b/docs/site/concepts/principal-vault-identity.mdx @@ -22,7 +22,7 @@ An **Identity** does not own credentials directly. Instead, an Identity claims a Here is how the initialization and claim flow works: -1. **Initialize Identity:** Running `authsome init` creates a new Identity and registers it with the daemon. The daemon returns a claim URL. +1. **Initialize Identity:** Running `authsome onboard` creates a new Identity and registers it with the daemon. The daemon returns a claim URL. 2. **Register/Login Principal:** The user opens the claim URL and signs in (with email and password). The first Principal created becomes the server `admin`, subsequent ones are `user`. 3. **Accept Claim:** The authenticated Principal confirms the claim, binding the Identity to the Principal and creating the Principal's default Vault. 4. **Access Granted:** Vault access is granted only after the claim is `accepted`. diff --git a/docs/site/installation.mdx b/docs/site/installation.mdx index eaebaf15..81093d71 100644 --- a/docs/site/installation.mdx +++ b/docs/site/installation.mdx @@ -83,7 +83,7 @@ If `doctor` reports failures, see [Diagnose with doctor](/troubleshooting/doctor ## First-run initialization -On `authsome init`, authsome initializes its home directory at `~/.authsome/`, creates a generated identity handle and Ed25519 DID, and registers that identity with the local daemon: +On `authsome onboard`, authsome initializes its home directory at `~/.authsome/`, creates a generated identity handle and Ed25519 DID, registers that identity with the daemon, completes claim, and imports API keys from `.env` files and the process environment: ```text ~/.authsome/ @@ -98,7 +98,7 @@ On `authsome init`, authsome initializes its home directory at `~/.authsome/`, c daemon/ ``` -On a fresh `init`, authsome resolves the master key source in this order: +On a fresh `onboard`, authsome resolves the master key source in this order: 1. `AUTHSOME_MASTER_KEY` from the environment, when set. The value must be a base64-encoded 32-byte key. 2. An existing OS keyring entry. @@ -110,7 +110,13 @@ Override the home location with `AUTHSOME_HOME` for ephemeral or per-project set ```bash export AUTHSOME_HOME=/var/lib/authsome -authsome init +authsome onboard +``` + +For a remote or self-hosted daemon, pass `--base-url` once; authsome saves it in client config for later commands: + +```bash +authsome onboard --base-url https://authsome.example.com ``` @@ -129,7 +135,7 @@ By default, authsome uses `encryption.mode = "auto"` and applies the precedence Re-run `authsome doctor` to confirm the backend is reachable. The trade-offs are covered in [Encryption at rest](/security/encryption). -Older installs that used the implicit `default` profile must run `authsome init` again. This release does not migrate credentials under old `profile:default:*` keys. +Older installs that used the implicit `default` profile must run `authsome onboard` again. This release does not migrate credentials under old `profile:default:*` keys. ## Optional: trust the proxy CA diff --git a/docs/site/reference/cli.mdx b/docs/site/reference/cli.mdx index 9bc80dbc..89628bff 100644 --- a/docs/site/reference/cli.mdx +++ b/docs/site/reference/cli.mdx @@ -13,13 +13,12 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress | `daemon` | Manage the local Authsome daemon. | | `doctor` | Run health checks on directory layout and encryption. | | `agent` | Manage local agents backed by signing keys. | -| `init` | Initialize local storage and register a fresh agent. | +| `onboard` | First-run setup: identity, claim, and API key import from env. | | `log` | View structured audit entries or the raw client debug log. | | `login ` | Authenticate with PROVIDER using the configured flow. | | `logout ` | Log out of the specified PROVIDER connection. | | `provider` | Manage provider definitions and provider-level operations. | | `run -- ` | Run COMMAND as a subprocess injected with authentication credentials. | -| `scan` | Scan env files and process env for provider API keys. | | `whoami` | Show basic local context. | ## Global flags @@ -35,15 +34,28 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress ## Command details -### `init` / `whoami` / `doctor` +### `onboard` / `whoami` / `doctor` ```bash -authsome init # initialize local storage and register agent -authsome whoami # show agent context and encryption mode -authsome doctor # run health checks -authsome doctor --json # structured output for monitoring +authsome onboard # identity + claim + import API keys from env +authsome onboard --base-url https://host:7998 # point at a remote daemon (saved for later runs) +authsome onboard --scan-only # drift report without importing +authsome whoami # show agent context and encryption mode +authsome doctor # run health checks +authsome doctor --json # structured output for monitoring ``` +`onboard` is idempotent — safe to re-run. It creates a local identity when needed, completes daemon registration and claim, then imports API keys discovered in `.env` files and the process environment. OAuth providers still require `authsome login `. + +When `--base-url` is passed, the URL is persisted in `~/.authsome/client/config.json` as `daemon_base_url` and used automatically on subsequent commands (unless overridden by `AUTHSOME_BASE_URL`). + +| Option | Description | +|--------|-------------| +| `--base-url ` | Daemon URL to connect to; saved in client config for future commands. | +| `--scan-only` | Report env/vault drift without importing discovered keys. | + +`onboard` does not support `--quiet`. Use `--json` for headless contexts. + `doctor` runs six checks and exits `0` when all pass: | Check | What it verifies | @@ -118,29 +130,6 @@ How it works: See [Proxy injection](/concepts/proxy-injection) for the routing contract. -### `scan` - -```bash -authsome scan [OPTIONS] -``` - -Scans `.env` files in the current directory tree and the active process environment for credentials matching bundled providers. By default it prints a drift report (what's in your env vs. what's in the vault). - -| Option | Description | -|--------|-------------| -| `--import` | Apply the discovered values, creating connections in the vault. | -| `--connection ` | Target a non-default connection name when importing. | -| `--json` | Machine-readable drift report. Combine with `--import` to apply. | - -```bash -authsome scan # report-only -authsome scan --json # report-only, JSON output -authsome scan --import # write discovered keys into the vault -authsome scan --import --connection ci # import into a named connection -``` - -`scan` does not support `--quiet`. Use `--json` for headless contexts. - ### `connections set-default` ```bash @@ -218,7 +207,7 @@ authsome log --raw # raw client debug log (loguru format) authsome log --raw -n 20 # last 20 lines of the client debug log ``` -Reads from `~/.authsome/server/logs/authsome.log` (the server-side structured audit log). Each entry records actions like `login`, `logout`, `revoke`, and `scan`, with fields: `timestamp`, `event`, `provider`, `connection`, `identity`, `status`. +Reads from `~/.authsome/server/logs/authsome.log` (the server-side structured audit log). Each entry records actions like `login`, `logout`, `revoke`, and `onboard`, with fields: `timestamp`, `event`, `provider`, `connection`, `identity`, `status`. The `--raw` flag switches to the client-side debug log at `~/.authsome/client/logs/authsome.log` (loguru format, DEBUG level). diff --git a/docs/site/reference/python-library.mdx b/docs/site/reference/python-library.mdx index 734eeffd..ff7f226c 100644 --- a/docs/site/reference/python-library.mdx +++ b/docs/site/reference/python-library.mdx @@ -162,7 +162,7 @@ The exact session shape depends on the flow type. For automation, prefer driving ### Manage Principals and Identities -CLI-level Principal and Identity switching is managed via `authsome init` and `authsome login`. The library provides programmatic access to these constructs, but typically you rely on the daemon resolving the active context. +CLI-level Principal and Identity switching is managed via `authsome onboard` and `authsome login`. The library provides programmatic access to these constructs, but typically you rely on the daemon resolving the active context. ### Register a custom provider diff --git a/docs/site/troubleshooting/doctor.mdx b/docs/site/troubleshooting/doctor.mdx index 897510fb..32f178a0 100644 --- a/docs/site/troubleshooting/doctor.mdx +++ b/docs/site/troubleshooting/doctor.mdx @@ -32,7 +32,7 @@ A healthy machine prints `OK` for each check and exits with code `0`. Causes: - - You haven't initialized authsome on this machine. Run `authsome init` to provision the home directory, identity, and master key. + - You haven't initialized authsome on this machine. Run `authsome onboard` to provision the home directory, identity, and master key. - You moved or deleted `~/.authsome`. If you have a backup, restore it. Without the master key, encrypted records cannot be decrypted. diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index b057acff..03b2e780 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -14,6 +14,7 @@ from fastapi import status import authsome.errors as err_mod +from authsome.cli.config import ClientConfig from authsome.cli.identity import RuntimeIdentity from authsome.config import get_authsome_config from authsome.identity.proof import POP_AUTH_SCHEME, create_proof_jwt @@ -22,11 +23,21 @@ API_PREFIX = "/api" -def resolve_daemon_url(env: Mapping[str, str] | None = None) -> str: - """Return the top-level configured Authsome server URL.""" - configured = get_authsome_config().base_url - raw = (env or {}).get("AUTHSOME_BASE_URL", configured).strip() - return raw.rstrip("/") or configured +def resolve_daemon_url(env: Mapping[str, str] | None = None, home: Path | None = None) -> str: + """Return the top-level configured Authsome server URL. + + Precedence: ``AUTHSOME_BASE_URL`` env, then ``daemon_base_url`` in client + config (set by ``authsome onboard --base-url``), then the default from + ``AuthsomeConfig``. + """ + configured = get_authsome_config(home).base_url.rstrip("/") + raw = (env or {}).get("AUTHSOME_BASE_URL", "").strip() + if raw: + return raw.rstrip("/") or configured + saved = ClientConfig.load(home).daemon_base_url + if saved: + return saved.rstrip("/") + return configured def is_local_daemon_url(url: str) -> bool: diff --git a/src/authsome/cli/commands/__init__.py b/src/authsome/cli/commands/__init__.py index 804870c4..9bd13085 100644 --- a/src/authsome/cli/commands/__init__.py +++ b/src/authsome/cli/commands/__init__.py @@ -10,10 +10,9 @@ def register_commands(cli) -> None: """Attach command groups and root commands to the root CLI.""" cli.add_command(core_module.login) - cli.add_command(core_module.scan) + cli.add_command(core_module.onboard) cli.add_command(core_module.logout) cli.add_command(core_module.run) - cli.add_command(core_module.init) cli.add_command(core_module.whoami) cli.add_command(core_module.doctor) cli.add_command(core_module.log_cmd) diff --git a/src/authsome/cli/commands/core.py b/src/authsome/cli/commands/core.py index 4ec4e942..6fa5cd2d 100644 --- a/src/authsome/cli/commands/core.py +++ b/src/authsome/cli/commands/core.py @@ -12,6 +12,7 @@ from authsome.auth.flows.browser import BrowserFlow from authsome.auth.models.enums import AuthType from authsome.auth.models.provider import ProviderDefinition +from authsome.cli.config import ClientConfig from authsome.cli.context import ContextObj from authsome.cli.helpers import ( _api_key_env_var, @@ -48,83 +49,14 @@ def _build_login_json_payload(session_info: dict[str, Any], provider: str, conne return payload -@click.command() -@click.argument("provider") -@click.option("--connection", default="default", metavar="NAME", help="Connection name.") -@click.option( - "--flow", - type=click.Choice([e.value for e in FlowType], case_sensitive=False), - metavar="FLOW", - help=f"Authentication flow override ({', '.join(e.value for e in FlowType)}).", -) -@click.option("--scopes", metavar="SCOPES", help="Comma-separated list of permission scopes to request.") -@click.option("--base-url", metavar="URL", help="Override provider API base URL (e.g. for self-hosted enterprise).") -@click.option("--force", is_flag=True, help="Overwrite an existing connection if it already exists.") -@auth_command -async def login( # noqa: PLR0913 - ctx_obj: ContextObj, - provider: str, +async def _run_credential_scan( # noqa: PLR0912, PLR0915 + actx: ContextObj, + *, connection: str, - flow: str | None, - scopes: str | None, - base_url: str | None, - force: bool, -) -> None: - """Authenticate with PROVIDER using the configured flow.""" - actx = await ctx_obj.initialize() - flow_value = FlowType(flow).value if flow else None - scope_list = [s.strip() for s in scopes.split(",")] if scopes else None - - session_info = await actx.runtime_client.start_login( - provider=provider, - connection=connection, - flow=flow_value, - scopes=scope_list, - base_url=base_url, - force=force, - ) - login_result = _build_login_json_payload(session_info, provider, connection) - - if login_result["status"] != "success": - next_action = session_info.get("next_action", {"type": "none"}) - action_type = next_action.get("type") - - if action_type == "open_url": - auth_url = next_action["url"] - - with suppress(Exception): - webbrowser.open(auth_url) - - elif action_type == "browser": - credentials = await BrowserFlow.run_login(next_action, provider) - session_info = await actx.runtime_client.resume_login_session(session_info["id"], credentials=credentials) - login_result = _build_login_json_payload(session_info, provider, connection) - - logger.info( - "client_event event=login provider={} connection={} flow={} status={}", - provider, - connection, - flow or "unknown", - login_result["status"], - ) - - ctx_obj.print_json(login_result) - - -@click.command(name="scan") -@click.option("--connection", default="default", metavar="NAME", help="Connection name.") -@click.option("--import", "auto_import", is_flag=True, help="Import detected keys without interactive prompt.") -@auth_command -async def scan(ctx_obj: ContextObj, connection: str, auto_import: bool) -> None: # noqa: PLR0912, PLR0915 - """Scan env files and process env for provider API keys. - - Returns a drift report by default unless ``--import`` is also passed. - """ - # TODO: Looks duplicated with helpers _scan_env_sources - if ctx_obj.quiet: - raise click.UsageError("'scan' does not support --quiet. Use the default JSON output or --import to apply.") - - actx = await ctx_obj.initialize() + auto_import: bool, + event_name: str, +) -> dict[str, Any]: + """Scan env sources for API keys and optionally import them into the vault.""" scanned_env = _scan_env_sources() provider_defs: list[ProviderDefinition] = [] @@ -188,8 +120,8 @@ async def scan(ctx_obj: ContextObj, connection: str, auto_import: bool) -> None: should_import = _scan_resolve_should_import( auto_import=auto_import, configured_count=len(configured), - json_output=ctx_obj.json_output, - quiet=ctx_obj.quiet, + json_output=actx.json_output, + quiet=actx.quiet, ) imported = 0 @@ -225,20 +157,141 @@ async def scan(ctx_obj: ContextObj, connection: str, auto_import: bool) -> None: imported += 1 results.append({"provider": provider_name, "status": "imported", "env_var": item["env_var"]}) logger.info( - "client_event event=scan provider={} connection={} source={} source_env={} status=success", + "client_event event={} provider={} connection={} source={} source_env={} status=success", + event_name, provider_name, connection, item["source"], item["env_var"], ) + return { + "connection": connection, + "import": should_import, + "configured_count": len(configured), + "imported_count": imported, + "results": results, + } + + +@click.command() +@click.argument("provider") +@click.option("--connection", default="default", metavar="NAME", help="Connection name.") +@click.option( + "--flow", + type=click.Choice([e.value for e in FlowType], case_sensitive=False), + metavar="FLOW", + help=f"Authentication flow override ({', '.join(e.value for e in FlowType)}).", +) +@click.option("--scopes", metavar="SCOPES", help="Comma-separated list of permission scopes to request.") +@click.option("--base-url", metavar="URL", help="Override provider API base URL (e.g. for self-hosted enterprise).") +@click.option("--force", is_flag=True, help="Overwrite an existing connection if it already exists.") +@auth_command +async def login( # noqa: PLR0913 + ctx_obj: ContextObj, + provider: str, + connection: str, + flow: str | None, + scopes: str | None, + base_url: str | None, + force: bool, +) -> None: + """Authenticate with PROVIDER using the configured flow.""" + actx = await ctx_obj.initialize() + flow_value = FlowType(flow).value if flow else None + scope_list = [s.strip() for s in scopes.split(",")] if scopes else None + + session_info = await actx.runtime_client.start_login( + provider=provider, + connection=connection, + flow=flow_value, + scopes=scope_list, + base_url=base_url, + force=force, + ) + login_result = _build_login_json_payload(session_info, provider, connection) + + if login_result["status"] != "success": + next_action = session_info.get("next_action", {"type": "none"}) + action_type = next_action.get("type") + + if action_type == "open_url": + auth_url = next_action["url"] + + with suppress(Exception): + webbrowser.open(auth_url) + + elif action_type == "browser": + credentials = await BrowserFlow.run_login(next_action, provider) + session_info = await actx.runtime_client.resume_login_session(session_info["id"], credentials=credentials) + login_result = _build_login_json_payload(session_info, provider, connection) + + logger.info( + "client_event event=login provider={} connection={} flow={} status={}", + provider, + connection, + flow or "unknown", + login_result["status"], + ) + + ctx_obj.print_json(login_result) + + +@click.command(name="onboard") +@click.option( + "--base-url", + metavar="URL", + help="Daemon base URL to use and persist in client config for future commands.", +) +@click.option( + "--scan-only", + is_flag=True, + help="Report env/vault drift without importing discovered keys.", +) +@auth_command +async def onboard(ctx_obj: ContextObj, base_url: str | None, scan_only: bool) -> None: + """First-run setup: identity, claim, and API key import from env files. + + Idempotent — safe to re-run. Discovered keys matching the vault are skipped. + """ + if ctx_obj.quiet: + raise click.UsageError( + "'onboard' does not support --quiet. Use the default JSON output or omit --scan-only to import." + ) + + home = get_authsome_config().home + if base_url is not None: + normalized = base_url.strip().rstrip("/") + if not normalized: + raise click.UsageError("--base-url must be a non-empty URL.") + ClientConfig.load(home).model_copy(update={"daemon_base_url": normalized}).save(home) + + RuntimeIdentity.ensure_local(home) + + actx = await ctx_obj.initialize() + identity = await actx.runtime_client.ensure_identity_ready() + whoami_data = await actx.runtime_client.whoami() + client_config = ClientConfig.load(home) + + scan_result = await _run_credential_scan( + actx, + connection="default", + auto_import=not scan_only, + event_name="onboard", + ) + ctx_obj.print_json( { - "connection": connection, - "import": should_import, - "configured_count": len(configured), - "imported_count": imported, - "results": results, + "status": "onboarded", + "home": str(home), + "agent": identity.handle, + "did": identity.did, + "registration_status": "registered", + "daemon_base_url": client_config.daemon_base_url or actx.runtime_client.base_url, + "configured_encryption_mode": whoami_data.get("configured_encryption_mode"), + "effective_encryption_source": whoami_data.get("effective_encryption_source"), + "encryption_backend": whoami_data.get("encryption_backend"), + **scan_result, } ) @@ -266,30 +319,6 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None: sys.exit(result.returncode) -@click.command() -@auth_command -async def init(ctx_obj: ContextObj) -> None: - """Initialize local storage and register a fresh agent.""" - home = get_authsome_config().home - RuntimeIdentity.ensure_local(home) - - actx = await ctx_obj.initialize() - identity = await actx.runtime_client.ensure_identity_ready() - whoami_data = await actx.runtime_client.whoami() - - data = { - "status": "initialized", - "home": str(home), - "agent": identity.handle, - "did": identity.did, - "registration_status": "registered", - "configured_encryption_mode": whoami_data.get("configured_encryption_mode"), - "effective_encryption_source": whoami_data.get("effective_encryption_source"), - "encryption_backend": whoami_data.get("encryption_backend"), - } - ctx_obj.print_json(data) - - @click.command() @auth_command async def whoami(ctx_obj: ContextObj) -> None: diff --git a/src/authsome/cli/config.py b/src/authsome/cli/config.py index 18ac4fac..e4cf6f43 100644 --- a/src/authsome/cli/config.py +++ b/src/authsome/cli/config.py @@ -18,6 +18,7 @@ class ClientConfig(AuthsomeConfig): """ active_identity: str | None = None + daemon_base_url: str | None = None proxy_ca_installed: bool = False proxy_mode: ProxyMode = "connected_allow" diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py deleted file mode 100644 index 10cd6a0a..00000000 --- a/tests/cli/test_init.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for `authsome init`.""" - -import json -from pathlib import Path - -from authsome import __version__ -from authsome.cli.config import ClientConfig -from authsome.cli.identity import RuntimeIdentity -from authsome.cli.main import cli - - -def test_init_removes_legacy_default_state_and_registers_identity( - runner, - mock_client, - tmp_path: Path, -) -> None: - identities = tmp_path / "client" / "identities" - identities.mkdir(parents=True) - (identities / "default.json").write_text("{}", encoding="utf-8") - (identities / "default.key").write_text("legacy\n", encoding="utf-8") - - created = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") - mock_client.ensure_identity_ready.return_value = created - - result = runner.invoke(cli, ["--log-file", "", "init"]) - - assert result.exit_code == 0, result.output - data = json.loads(result.output) - assert data["agent"] != "default" - assert "profile" not in data - assert data["registration_status"] == "registered" - assert data["configured_encryption_mode"] == "auto" - assert data["effective_encryption_source"] == "local_key" - assert data["encryption_backend"] == "Local File (/home/test/.authsome/server/master.key)" - assert not (identities / "default.json").exists() - assert not (identities / "default.key").exists() - mock_client.ensure_identity_ready.assert_called_once() - mock_client.whoami.assert_called_once() - - config_data = ClientConfig.load(tmp_path) - assert config_data.version == __version__ - assert config_data.active_identity == data["agent"] - - -def test_init_skips_registration_for_registered_active_agent( - runner, - mock_client, - tmp_path: Path, -) -> None: - identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") - ClientConfig(active_identity=identity.handle).save(tmp_path) - - result = runner.invoke(cli, ["--log-file", "", "init"]) - - assert result.exit_code == 0, result.output - data = json.loads(result.output) - assert data["agent"] == identity.handle - assert "profile" not in data - assert data["configured_encryption_mode"] == "auto" - mock_client.ensure_identity_ready.assert_called_once() diff --git a/tests/cli/test_import_env.py b/tests/cli/test_onboard.py similarity index 52% rename from tests/cli/test_import_env.py rename to tests/cli/test_onboard.py index 41a48717..2de1817a 100644 --- a/tests/cli/test_import_env.py +++ b/tests/cli/test_onboard.py @@ -1,7 +1,11 @@ -"""Tests for `authsome scan`.""" +"""Tests for `authsome onboard`.""" import json +from pathlib import Path +from authsome import __version__ +from authsome.cli.config import ClientConfig +from authsome.cli.identity import RuntimeIdentity from authsome.cli.main import cli @@ -28,10 +32,12 @@ def _oauth_provider(name: str) -> dict: } -class TestScanCommand: - """Behavior tests for scan and import workflow.""" +class TestOnboardCommand: + """Behavior tests for onboard setup and credential import.""" - def test_scan_import_flag_imports_key_from_dotenv(self, runner, mock_client, monkeypatch, tmp_path) -> None: + def test_onboard_creates_identity_and_imports_key_from_dotenv( + self, runner, mock_client, monkeypatch, tmp_path: Path + ) -> None: mock_client.list_connections.return_value = { "connections": [], "by_source": { @@ -42,20 +48,60 @@ def test_scan_import_flag_imports_key_from_dotenv(self, runner, mock_client, mon mock_client.get_connection.side_effect = Exception("not found") mock_client.start_login.return_value = {"id": "sess-1", "status": "pending"} mock_client.resume_login_session.return_value = {"id": "sess-1", "status": "completed"} + created = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + mock_client.ensure_identity_ready.return_value = created monkeypatch.chdir(tmp_path) (tmp_path / ".env").write_text("BREVO_API_KEY=test123\n", encoding="utf-8") - result = runner.invoke(cli, ["--log-file", "", "scan", "--import"]) + result = runner.invoke(cli, ["--log-file", "", "onboard"]) assert result.exit_code == 0, result.output + mock_client.ensure_identity_ready.assert_called_once() + mock_client.whoami.assert_called_once() mock_client.start_login.assert_called_once_with( provider="brevo", connection="default", flow="api_key", force=True ) mock_client.resume_login_session.assert_called_once_with("sess-1", api_key="test123") data = json.loads(result.output) + assert data["status"] == "onboarded" assert data["import"] is True + assert data["imported_count"] == 1 - def test_scan_defaults_to_report_only(self, runner, mock_client, monkeypatch) -> None: + def test_onboard_removes_legacy_default_state(self, runner, mock_client, tmp_path: Path) -> None: + identities = tmp_path / "client" / "identities" + identities.mkdir(parents=True) + (identities / "default.json").write_text("{}", encoding="utf-8") + (identities / "default.key").write_text("legacy\n", encoding="utf-8") + + created = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + mock_client.ensure_identity_ready.return_value = created + + result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["agent"] != "default" + assert data["registration_status"] == "registered" + assert not (identities / "default.json").exists() + assert not (identities / "default.key").exists() + mock_client.ensure_identity_ready.assert_called_once() + + config_data = ClientConfig.load(tmp_path) + assert config_data.version == __version__ + assert config_data.active_identity == data["agent"] + + def test_onboard_skips_registration_for_registered_active_agent(self, runner, mock_client, tmp_path: Path) -> None: + identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") + ClientConfig(active_identity=identity.handle).save(tmp_path) + + result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["agent"] == identity.handle + mock_client.ensure_identity_ready.assert_called_once() + + def test_onboard_scan_only_does_not_import(self, runner, mock_client, monkeypatch) -> None: mock_client.list_connections.return_value = { "connections": [], "by_source": {"bundled": [_api_key_provider("openai", "OPENAI_API_KEY")], "custom": []}, @@ -63,7 +109,7 @@ def test_scan_defaults_to_report_only(self, runner, mock_client, monkeypatch) -> monkeypatch.setenv("OPENAI_API_KEY", "sk-test-value") mock_client.get_connection.return_value = {} - result = runner.invoke(cli, ["--log-file", "", "scan"]) + result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"]) assert result.exit_code == 0, result.output mock_client.start_login.assert_not_called() @@ -71,14 +117,14 @@ def test_scan_defaults_to_report_only(self, runner, mock_client, monkeypatch) -> data = json.loads(result.output) assert data["import"] is False - def test_scan_rejects_quiet_flag(self, runner, mock_client) -> None: - result = runner.invoke(cli, ["--log-file", "", "scan", "--quiet"]) + def test_onboard_rejects_quiet_flag(self, runner, mock_client) -> None: + result = runner.invoke(cli, ["--log-file", "", "onboard", "--quiet"]) assert result.exit_code == 1 data = json.loads(result.output) assert data["error"] == "UsageError" mock_client.list_connections.assert_not_called() - def test_scan_reports_drift_states(self, runner, mock_client, monkeypatch) -> None: + def test_onboard_reports_drift_states(self, runner, mock_client, monkeypatch) -> None: mock_client.list_connections.return_value = { "connections": [], "by_source": { @@ -103,7 +149,7 @@ def _get_connection(provider: str, connection: str): mock_client.get_connection.side_effect = _get_connection - result = runner.invoke(cli, ["--log-file", "", "scan"]) + result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"]) assert result.exit_code == 0, result.output payload = json.loads(result.output) @@ -111,3 +157,14 @@ def _get_connection(provider: str, connection: str): assert statuses["openai"] == "env_and_authsome_different" assert statuses["brevo"] == "authsome_only" assert statuses["resend"] == "both_missing" + + def test_onboard_persists_base_url_in_client_config(self, runner, mock_client, tmp_path: Path) -> None: + result = runner.invoke( + cli, + ["--log-file", "", "onboard", "--base-url", "https://authsome.example.com", "--scan-only"], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["daemon_base_url"] == "https://authsome.example.com" + assert ClientConfig.load(tmp_path).daemon_base_url == "https://authsome.example.com"