diff --git a/README.md b/README.md index 1a1589c..a60782d 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,134 @@ # unstract-cli -`unstract` — one CLI for the Unstract suite: extract a document with -LLMWhisperer, run it through a Document Studio API deployment, get structured -JSON back. It also clones one organization's resources into another. +`unstract` runs [LLMWhisperer](https://docs.unstract.com/llmwhisperer/) text +extraction and [Unstract](https://docs.unstract.com/unstract/) API deployments +from the terminal. Pass `-o json` and every command prints one JSON envelope, so a +shell script or a coding agent can drive it. + +Full reference: + +## Install ```bash curl -LsSf https://raw.githubusercontent.com/Zipstack/unstract-cli/main/install.sh | sh -unstract auth login # asks for your keys, checks them, stores them -unstract docstudio deployment ls # what can I run? ``` -For an agent or CI, no prompts and no file — the environment is the profile: +The installer fetches [`uv`](https://docs.astral.sh/uv/) if it is missing and +installs the CLI with it; `uv` brings its own Python. With `uv` or `pip` +already there: + +```bash +uv tool install unstract-cli # or: pip install unstract-cli +unstract --version +``` + +## Get your keys + +| Key | Where it is minted | What it does | +| --- | --- | --- | +| **Platform key** | An organisation admin, under **Settings → Platform API Keys** in the Unstract UI | Platform related operations and to identify the organization | +| **Deployment key** | The API deployment's own page in the Unstract UI or an organisation admin mints one under **Settings → Global API Deployment Keys** | Runs deployments (`deployment run`, `deployment status`) | +| **LLMWhisperer key** | The LLMWhisperer console | Extracts text (`whisper …`) | + +## Set up + +```bash +unstract auth login +``` + +A wizard asks for each product's URL (Enter keeps the cloud host; self-hosted, +type your own) and API keys, then writes `~/.unstract/config.toml`. Then verify: ```bash -export UNSTRACT_ORG_ID=... UNSTRACT_DEPLOYMENT_KEY=... LLMWHISPERER_API_KEY=... -unstract -o json whisper extract ./doc.pdf -unstract -o json docstudio deployment run invoice-parser ./doc.pdf +unstract auth whoami # which organisation the platform key belongs to +unstract config doctor --probe # where each setting resolved from, keys checked ``` -The installer fetches `uv` if it is missing and installs the CLI with it; `uv` -brings its own Python, so nothing on the machine has to match. Already have -`uv`? `uv tool install git+https://github.com/Zipstack/unstract-cli` is the same -thing. Set `UNSTRACT_CLI_SOURCE` to install a branch or a local checkout -instead. +## Configuration -Or run it without installing: `uvx --from git+https://github.com/Zipstack/unstract-cli unstract --discover groups`. +`~/.unstract/config.toml`, or `$UNSTRACT_CONFIG`, or `--config`, or a +project-local `.unstract.toml` is found by upward search. Here's an example config that uses environment variables for the API keys. -## Output +```toml +default_profile = "cloud-us" + +[profiles.cloud-us.docstudio] +base_url = "https://us-central.unstract.com" +org_id = "org_ABC123" +platform_key = "env:UNSTRACT_PLATFORM_KEY" +api_key = "env:UNSTRACT_DEPLOYMENT_KEY" + +[profiles.cloud-us.llmwhisperer] +base_url = "https://llmwhisperer-api.us-central.unstract.com/api/v2" +api_key = "env:LLMWHISPERER_API_KEY" + +# Only for a deployment whose key differs from the profile's. +[profiles.cloud-us.deployments."invoice-parser"] +api_key = "env:INVOICE_PARSER_KEY" +``` + +Every setting resolves **flag > environment > profile > built-in default**. +`auth login` writes keys literally; `env:VAR_NAME` keeps them out of the file. +The connection flags on each group (`--base-url`, `--api-key`, `--org-id`, +`--platform-key`) override the profile for one invocation without writing +anything. `config doctor` reports where each setting resolved from without +echoing a value, and exits non-zero when one of its checks fails. + +### Environment variables + +The same settings without a file, for CI, containers and agents: + +```bash +export UNSTRACT_PLATFORM_KEY=... # auth whoami, deployment ls +export UNSTRACT_DEPLOYMENT_KEY=... # deployment run / status +export UNSTRACT_ORG_ID=... # the organisation id auth whoami reports +export LLMWHISPERER_API_KEY=... # whisper … +export UNSTRACT_BASE_URL=... # self-hosted only +export LLMWHISPERER_BASE_URL=... # self-hosted only +``` -`unstract` prints a table by default — in a terminal and in a pipe alike, so -what you see while trying something is what a script sees running it. +`auth login` also takes each key as a flag (`--platform-key`, `--deployment-key`, +`--llmwhisperer-key`; `-` reads it from stdin) and the host as `--base-url`, so +it runs without a terminal too. + +## Usage + +```bash +# Extract text from a document (path or URL); waits for the result +unstract whisper extract invoice.pdf -o raw > invoice.txt + +# What deployments can I run? +unstract docstudio deployment ls + +# Run one and wait for the structured result +unstract docstudio deployment run invoice-parser invoice.pdf + +# Long job: submit, then check later +unstract docstudio deployment run invoice-parser invoice.pdf --no-wait +unstract docstudio deployment status invoice-parser +``` + +`--help` on any command lists its options; `unstract --discover full` prints +the whole command tree, every flag and the exit-code table as JSON. + +> **Note:** `clone` moves one organisation's resources into another using two +> admin platform keys, `UNSTRACT_SRC_PLATFORM_KEY` and `UNSTRACT_TGT_PLATFORM_KEY`. + +## Output for scripts and agents **Parsing anything? Pass `-o json`.** stdout then carries exactly one envelope, -on success and on failure alike: +on success and on failure alike, and diagnostics go to stderr: ```json {"ok": true, "data": {...}, "error": null, "meta": {"contract_version": 1}} ``` -`-o json` output depends on nothing but the command and its arguments — not the -terminal, not the config, not the environment. `-o raw` prints one field -unwrapped, for piping a document's text somewhere else. Diagnostics, warnings -and progress always go to stderr. - -Consuming the JSON: ignore fields you do not recognise, and refuse a -`meta.contract_version` above the one you were written against. `unstract ---discover full` publishes the whole contract alongside every command and flag. +Ignore fields you do not recognise; refuse a `meta.contract_version` above the +one you were written against. `-o raw` prints one field unwrapped. When a +coding agent is driving (detected from the environment it sets) json is the +default; `--agent yes|no` forces that, and an explicit `-o` wins over both. -If a coding agent is driving (detected from the environment it sets), the -*default* becomes json. `--agent yes|no` forces that either way, and an explicit -`-o` always wins over both. - -Failures exit non-zero with a stable code. The codes are this CLI's own -convention, not a service's — they are the `ExitCode` enum in -`core/errors.py`, and `--discover full` publishes the table so a caller does not -have to copy it: +Failures exit non-zero with a stable code: | Code | Meaning | |------|---------| @@ -71,111 +145,10 @@ have to copy it: | 10 | the result was read but could not be saved — it is in `error.details` | | 130 | interrupted (128 + SIGINT) — the user stopped it, not a failure | -## Credentials - -Three keys, each for one job: - -- **LLMWhisperer key** — extracts text (`whisper …`). Minted in the LLMWhisperer - console. -- **Deployment key** — runs deployments (`deployment run`, `deployment status`). - Shown on the API deployment's own page in the Unstract UI; one an - organisation admin mints under **Settings → Global API Deployment Keys** - covers every deployment in the organisation. -- **Platform key** — identifies the organisation and lists what is in it - (`auth whoami`, `deployment ls`). Minted by an organisation admin under - **Settings → Platform API Keys**. - -`auth login` takes whichever of the three you have, checks the two it can -(`whoami` for the platform key, the usage endpoint for the LLMWhisperer key; a -deployment key has nothing side-effect-free to call and is stored as given) and -writes them to one profile. Run it again to rotate a key. A login that stores a -different host drops the profile's other keys rather than leave them beside a -server that never accepted them: it asks first, or without a terminal fails -until `--force`. Without a terminal pass them as flags — `--platform-key`, `--deployment-key`, `--llmwhisperer-key`, -any one of them `-` to read from stdin. - -## Configuration - -`~/.unstract/config.toml`, or a project-local `.unstract.toml` found by upward -search, or `$UNSTRACT_CONFIG`, or `--config`. Every setting resolves -**flag > env > profile > built-in default**, and the CLI is fully usable with no -config file at all. The flag tier is the connection options on each product -group — `--base-url`, `--api-key`, `--org-id` and `--platform-key` on -`docstudio`, `--base-url`/`--api-key` on `whisper`, `--base-url`/`--platform-key` -on `auth` — which override the profile for that one invocation without writing -anything. - -```toml -default_profile = "cloud-us" - -[profiles.cloud-us.llmwhisperer] -base_url = "https://llmwhisperer-api.us-central.unstract.com/api/v2" -api_key = "env:LLMWHISPERER_API_KEY" - -[profiles.cloud-us.docstudio] -base_url = "https://us-central.unstract.com" -org_id = "org_ABC123" -api_key = "env:UNSTRACT_DEPLOYMENT_KEY" -platform_key = "env:UNSTRACT_PLATFORM_KEY" - -# Only for a deployment whose key differs from the one above. -[profiles.cloud-us.deployments."invoice-parser"] -api_key = "env:INVOICE_PARSER_KEY" -``` - -`deployment run` and `deployment status` take the API name as `deployment ls` -prints it. `ls` itself authenticates with the platform key and refuses -`--api-key`, which on `docstudio` means a deployment key. The key for a run resolves `--api-key` > `$UNSTRACT_DEPLOYMENT_KEY` > -the deployment's own entry > the profile's `api_key`, so most profiles need no -`deployments` section at all; `config set docstudio api_key --deployment -` writes one. `org_id` lives on the `docstudio` block — `auth login` -and `auth whoami` write the one the platform key resolves there. `config init` -writes this shape minus `platform_key` and the `deployments` entry — both are -the exception, not the starting point — plus a `cloud-eu` profile and an -`onprem-example` shape to copy for a self-hosted install; only the *active* -profile is ever resolved. - -Either form works for a credential. `auth login` writes keys literally, having -checked them at the moment it writes. `env:VAR_NAME` indirection — what `config -init` writes and what the example above uses — keeps the secret out of the file, -so it stays safe to copy or commit; that is the form for a shared machine or a -CI checkout. Either way the file is created `0600`, and `config doctor` warns -when its mode is wider than that. - -`unstract config doctor` reports where each setting resolved from — including -whether an `env:` reference is actually set in the current process — without -echoing any value. `--probe` also checks the two keys that can be -checked — the platform key and the LLMWhisperer key, the same two `auth login` -checks — and, with a platform key, warns about a `deployments` entry the -organisation no longer has. It exits non-zero when one of its own checks failed, so a setup script can -branch on it. - -A project-local `.unstract.toml` **found by upward search** may not supply a -key or `base_url`. Those are ignored, with a warning; everything else in it — -profile selection, `org_id` — applies as usual. A checkout you did not write is -not trusted to name the host your key is sent to. Name the file explicitly -(`--config` or `$UNSTRACT_CONFIG`) and it is honoured in full. - -What that protects is the key and the host, not the routing: `org_id` and -profile selection stay repo-controllable by design, so a project file can still -decide *which* organisation a command runs against on a host you trust. Read -one before you run inside a checkout you did not write. - -`clone` is the exception, and it is an operator command: a human moving one -organisation's resources into another, holding two admin Platform keys. It is -not part of the document-processing path the rest of this CLI wraps, so an agent -serving a user request should not reach for it unasked. It talks to two -deployments at once, which no single profile describes, so it takes both -endpoints as flags and both keys from `UNSTRACT_SRC_PLATFORM_KEY` / -`UNSTRACT_TGT_PLATFORM_KEY` — two keys for two organisations, so it reads -neither the profile's `platform_key` nor `$UNSTRACT_PLATFORM_KEY`. It exits 0 -when nothing failed, which is not the same as everything having moved: oversize -and unsupported documents are skipped by design, and `data.skipped` counts them. - ## Development ```bash -uv venv && uv pip install -e '.[dev]' -uv run pytest # offline; no network, no credentials +uv sync --extra dev +uv run pytest uv run ruff check . ``` diff --git a/install.sh b/install.sh index fdd8906..d5c24d9 100755 --- a/install.sh +++ b/install.sh @@ -4,8 +4,7 @@ # UNSTRACT_CLI_SOURCE=/path/to/checkout sh install.sh set -eu -# Flips to the bare PyPI name once the CLI is published there. -SOURCE="${UNSTRACT_CLI_SOURCE:-git+https://github.com/Zipstack/unstract-cli@main}" +SOURCE="${UNSTRACT_CLI_SOURCE:-unstract-cli}" if ! command -v uv >/dev/null 2>&1; then echo "Installing uv..." >&2 @@ -22,12 +21,13 @@ if ! command -v uv >/dev/null 2>&1; then fi # uv fetches its own interpreter, so the CLI's Python floor is not the user's problem. -uv tool install --force "$SOURCE" +# A pre-release is taken only while no stable release exists. +uv tool install --force --prerelease if-necessary "$SOURCE" if command -v unstract >/dev/null 2>&1; then echo unstract --version 2>/dev/null || true - echo "Run 'unstract config init' to get started." >&2 + echo "Run 'unstract auth login' to get started." >&2 exit 0 fi diff --git a/pyproject.toml b/pyproject.toml index 9756245..903875e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,13 @@ dependencies = [ "requests>=2.32.3", ] +[project.urls] +Homepage = "https://unstract.com" +Documentation = "https://docs.unstract.com/unstract/unstract_platform/cli/unstract_cli/" +Repository = "https://github.com/Zipstack/unstract-cli" +Issues = "https://github.com/Zipstack/unstract-cli/issues" +Changelog = "https://github.com/Zipstack/unstract-cli/releases" + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 7375182..24ebc84 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -119,6 +119,23 @@ def config_init(obj: Any, force: bool) -> None: default_profile="cloud-us", profiles=starter_profiles(), path=path, exists=True ) written, meta = _saved(new, path) + variables = sorted( + { + value.removeprefix("env:") + for profile in new.profiles.values() + for block in profile.values() + for value in block.values() + if isinstance(value, str) and value.startswith("env:") + } + ) + diagnostic( + f"Wrote {written}.\n" + f"Next: export {' and '.join(variables)}, or run `unstract auth login` " + "to store keys in the file instead.\n" + "Then `unstract config doctor` shows what resolved.", + quiet=getattr(obj, "quiet", False), + verbosity=getattr(obj, "verbosity", 0), + ) emit_result( { "created": str(written), diff --git a/src/unstract_cli/commands/platform_cmd.py b/src/unstract_cli/commands/platform_cmd.py index 3a1f249..776e348 100644 --- a/src/unstract_cli/commands/platform_cmd.py +++ b/src/unstract_cli/commands/platform_cmd.py @@ -172,6 +172,24 @@ def _keys_from_flags(given: dict[str, str | None]) -> dict[str, str | None]: return keys +def _url(value: str) -> str: + parts = urlsplit(value.strip()) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise click.UsageError(f"{value.strip()!r} is not an http(s) URL.") + return value.strip() + + +def _hosts_from_prompts(resolved: ResolvedConfig) -> dict[str, str]: + """One visible prompt per product; only a host typed over the one shown counts.""" + hosts: dict[str, str] = {} + for product in (DOCSTUDIO, LLMWHISPERER): + shown = resolved.get(product, "base_url") + typed = _prompt(f"{product} base URL", default=shown, value_proc=_url) + if _host(typed) != _host(shown): + hosts[f"{product}.base_url"] = typed + return hosts + + def _keys_from_prompts() -> dict[str, str | None]: """One hidden, skippable prompt per credential, in the documented order.""" keys: dict[str, str | None] = {} @@ -189,7 +207,11 @@ def _keys_from_prompts() -> dict[str, str | None]: def _validation_config( - ctx: Context, cfg: ConfigFile, name: str, keys: dict[str, str | None] + ctx: Context, + cfg: ConfigFile, + name: str, + keys: dict[str, str | None], + hosts: dict[str, str] | None = None, ) -> ResolvedConfig: """The keys being stored, resolved as the profile they will land in. @@ -198,7 +220,7 @@ def _validation_config( profile that does not exist yet resolves against nothing but the flags and the environment, so a stranger's host cannot be the one that answers. """ - overrides = dict(ctx.overrides) + overrides = {**ctx.overrides, **(hosts or {})} for credential, _flag, _label, (product, key) in _CREDENTIALS: if keys.get(credential): overrides[f"{product}.{key}"] = keys[credential] @@ -310,9 +332,11 @@ def _new_profile_name(cfg: ConfigFile, org_id: str, org_name: Any) -> str: def login(ctx: Context, profile: str | None, force: bool, **given: str | None) -> None: """Store your keys in a profile, checking each one that can be checked. - At a terminal this asks for each key in turn -- platform, deployment, - LLMWhisperer -- and Enter skips one; at least one is needed. Without a - terminal, pass the keys as flags, one of them as `-` to read it from stdin. + At a terminal this first asks for each product's host, Enter keeping the + one shown, then for each key in turn -- platform, deployment, LLMWhisperer + -- and Enter skips one; at least one is needed. Without a terminal, pass + the keys as flags, one of them as `-` to read it from stdin, and the host + as `--base-url` if it is not the default. \b Examples: @@ -333,13 +357,8 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) - """ if given["platform"] is None: given["platform"] = ctx.overrides.get(f"{DOCSTUDIO}.platform_key") - if any(value is not None for value in given.values()): - keys = _keys_from_flags(given) - interactive = False - elif _interactive(): - keys = _keys_from_prompts() - interactive = True - else: + interactive = not any(value is not None for value in given.values()) + if interactive and not _interactive(): raise CLIError( "No keys were given and stdin is not a terminal, so there is nothing " "to ask for them with.", @@ -347,14 +366,6 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) - hint="Pass --platform-key, --deployment-key or --llmwhisperer-key, as a " "value or as `-` to read one of them from stdin.", ) - if not any(keys.values()): - raise CLIError( - "No key was given; at least one is needed.", ExitCode.USAGE, hint=KEY_SOURCES - ) - # A key given here is never read back through the config layer that - # registers one, so nothing else would scrub it out of an error payload. - for value in keys.values(): - remember_secret(value) try: cfg = _writable_config() @@ -366,7 +377,26 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) - except ConfigError as exc: raise CLIError(str(exc), ExitCode.USAGE) from exc - resolved = _validation_config(ctx, cfg, name, keys) + hosts: dict[str, str] = {} + if interactive: + click.echo( + "Welcome to Unstract -- let's connect this machine to your organisation.", + err=True, + ) + hosts = _hosts_from_prompts(_validation_config(ctx, cfg, name, {})) + keys = _keys_from_prompts() + else: + keys = _keys_from_flags(given) + if not any(keys.values()): + raise CLIError( + "No key was given; at least one is needed.", ExitCode.USAGE, hint=KEY_SOURCES + ) + # A key given here is never read back through the config layer that + # registers one, so nothing else would scrub it out of an error payload. + for value in keys.values(): + remember_secret(value) + + resolved = _validation_config(ctx, cfg, name, keys, hosts) timeout = getattr(ctx, "transport_timeout", None) result: dict[str, Any] = {"profile": name, "path": None} identity: dict[str, Any] = {} @@ -464,6 +494,9 @@ def login(ctx: Context, profile: str | None, force: bool, **given: str | None) - # An entry left with no key is still listed as a deployment the profile holds. if path[0] == "deployments" and not table: block["deployments"].pop(path[1], None) + for product in PRODUCTS: + if f"{product}.base_url" in hosts: + block.setdefault(product, {})["base_url"] = hosts[f"{product}.base_url"] noticed: set[str] = set() for credential, _flag, _label, (product, key) in _CREDENTIALS: if not keys[credential]: diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index bff7572..a174239 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -537,6 +537,8 @@ def require(self, product: str, key: str) -> Any: # lands in shell history and in the process list. if key not in SECRET_SETTINGS: hints.append(f"or pass --{key.replace('_', '-')}") + if not self.file.exists: + hints.append("or run `unstract auth login` to set up interactively") raise ConfigError( f"Missing required setting {product}.{key}. To fix: {'; '.join(hints)}." ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9da1c3d..a137f83 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -224,6 +224,17 @@ def test_init_refuses_to_clobber_without_force(capsys, tmp_path, monkeypatch): assert run(capsys, "config", "init", "--force")[0] == 0 +def test_init_says_what_to_do_next_on_stderr(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) + + code, payload, err = run(capsys, "config", "init") + + assert code == 0 and payload["ok"] is True + assert "export LLMWHISPERER_API_KEY and UNSTRACT_DEPLOYMENT_KEY" in err + assert "`unstract auth login`" in err + assert "config doctor" in err + + def test_doctor_reports_sources_without_leaking_values(capsys, monkeypatch): monkeypatch.setenv("LLMWHISPERER_API_KEY", "super-secret-value") code, payload, _ = run(capsys, "config", "doctor") diff --git a/tests/test_commands.py b/tests/test_commands.py index bda41ad..f29d432 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -10,6 +10,8 @@ import json import os import socket +import stat +import sys import click import httpx @@ -1576,6 +1578,13 @@ def test_whoami_reports_the_identity_the_service_returned( assert envelope(out)["data"] == IDENTITY +def test_a_fresh_install_is_pointed_at_login_rather_than_at_a_file(capsys): + code, _, err = run(capsys, "auth", "whoami") + + assert code == int(ExitCode.USAGE) + assert "run `unstract auth login`" in err + + def test_whoami_is_called_with_no_organisation( capsys, platform_client, monkeypatch, tmp_path ): @@ -2231,10 +2240,18 @@ def login_seams(monkeypatch, platform_client, tmp_path): def prompt(text, **kwargs): state["prompts"].append(text) - return state["answers"].pop(0) + state["defaults"].append(kwargs.get("default")) + while True: + answer = state["answers"].pop(0) + if answer == "" and kwargs.get("default"): + answer = kwargs["default"] + try: + return kwargs.get("value_proc", lambda value: value)(answer) + except click.UsageError as exc: + click.echo(f"Error: {exc.message}", err=True) def install(answers=(), *, confirm=False, tty=True, whoami=None, usage=None): - state["answers"], state["prompts"] = list(answers), [] + state["answers"], state["prompts"], state["defaults"] = list(answers), [], [] monkeypatch.setattr(platform_cmd, "_interactive", lambda: tty) monkeypatch.setattr(platform_cmd, "_prompt", prompt) confirms = list(confirm) if isinstance(confirm, list) else None @@ -2274,15 +2291,19 @@ def _written(tmp_path) -> str: def test_login_asks_for_each_key_in_turn_and_stores_them_as_literals( capsys, login_seams, tmp_path ): - """Interactive path: platform, deployment, LLMWhisperer, one hidden prompt - each. The two keys with a read-only endpoint are checked; the deployment - key is stored as given and said to be.""" - seams = login_seams([PK, DK, LK]) + """Interactive path: a visible host prompt per product, then platform, + deployment, LLMWhisperer, one hidden prompt each. The two keys with a + read-only endpoint are checked; the deployment key is stored as given and + said to be.""" + seams = login_seams(["", "", PK, DK, LK]) code, out, err = run(capsys, "auth", "login") assert code == int(ExitCode.SUCCESS) + assert err.startswith("Welcome to Unstract") assert [p.split(" (")[0] for p in seams["prompts"]] == [ + "docstudio base URL", + "llmwhisperer base URL", "Platform key", "Deployment key", "LLMWhisperer key", @@ -2310,7 +2331,7 @@ def test_login_asks_for_each_key_in_turn_and_stores_them_as_literals( def test_login_with_every_prompt_skipped_is_a_usage_error(capsys, login_seams, tmp_path): - login_seams(["", "", ""]) + login_seams(["", "", "", "", ""]) code, out, _ = run(capsys, "auth", "login") @@ -2320,7 +2341,7 @@ def test_login_with_every_prompt_skipped_is_a_usage_error(capsys, login_seams, t def test_login_with_only_a_deployment_key_calls_nothing(capsys, login_seams, tmp_path): - seams = login_seams(["", DK, ""]) + seams = login_seams(["", "", "", DK, ""]) code, out, _ = run(capsys, "auth", "login") @@ -2411,15 +2432,33 @@ def test_login_reads_at_most_one_key_from_stdin(capsys, login_seams, tmp_path): assert not (tmp_path / "config.toml").exists() +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes") +def test_login_creates_the_config_file_and_its_directory_owner_only( + capsys, login_seams, tmp_path, monkeypatch +): + """A fresh install has no file and no directory; login is the first thing + documented to run, so it must not need `config init` before it.""" + path = tmp_path / "fresh" / ".unstract" / "config.toml" + monkeypatch.setenv("UNSTRACT_CONFIG", str(path)) + login_seams([]) + + code, _, _ = run(capsys, "auth", "login", "--platform-key", PK) + + assert code == int(ExitCode.SUCCESS) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert f'platform_key = "{PK}"' in path.read_text(encoding="utf-8") + + def test_login_takes_the_platform_key_from_the_group_flag_too( capsys, login_seams, tmp_path ): seams = login_seams([]) - code, _, _ = run(capsys, "auth", "--platform-key", PK, "login") + code, _, err = run(capsys, "auth", "--platform-key", PK, "login") assert code == int(ExitCode.SUCCESS) assert seams["prompts"] == [] + assert "Welcome" not in err assert f'platform_key = "{PK}"' in _written(tmp_path) @@ -2442,7 +2481,7 @@ def test_login_writes_nothing_when_any_key_is_rejected( if rejected == "llmwhisperer" else None, } - login_seams([PK, DK, LK], **kwargs) + login_seams(["", "", PK, DK, LK], **kwargs) code, out, _ = run(capsys, "auth", "login") @@ -2456,9 +2495,9 @@ def test_login_again_replaces_the_keys_given_and_keeps_the_rest( ): """Rotation: the same profile, updated in place, and a same-organisation re-run asks nothing.""" - login_seams([PK, DK, LK]) + login_seams(["", "", PK, DK, LK]) run(capsys, "auth", "login") - seams = login_seams(["", "dk-rotated-000001", ""]) + seams = login_seams(["", "", "", "dk-rotated-000001", ""]) code, _, _ = run(capsys, "auth", "login") @@ -2506,10 +2545,10 @@ def test_login_offers_a_new_profile_named_after_the_organisation( ): """Interactive: the profile the key belongs to is a new one, suggested from the organisation's display name, and the old profile is untouched.""" - login_seams([PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"}) + login_seams(["", "", PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"}) run(capsys, "auth", "login") seams = login_seams( - [PK, "", "", "beta-corp"], + ["", "", PK, "", "", "beta-corp"], confirm=True, whoami={ **IDENTITY, @@ -2540,7 +2579,7 @@ def test_a_new_profile_offered_by_the_guard_keeps_the_host_the_key_was_checked_o encoding="utf-8", ) seams = login_seams( - [PK, "", "", "beta"], + ["", "", PK, "", "", "beta"], confirm=True, whoami={**IDENTITY, "organization_id": "org_NEW"}, ) @@ -2569,7 +2608,7 @@ def test_a_profile_chosen_at_the_guard_is_replaced_not_merged_into( encoding="utf-8", ) seams = login_seams( - [PK, "", "", "beta"], + ["", "", PK, "", "", "beta"], confirm=True, whoami={**IDENTITY, "organization_id": "org_NEW"}, ) @@ -2597,7 +2636,7 @@ def test_a_new_profile_name_that_belongs_to_a_third_organisation_is_confirmed( encoding="utf-8", ) seams = login_seams( - [PK, "", "", "partner", "fresh"], + ["", "", PK, "", "", "partner", "fresh"], confirm=[True, False], whoami={**IDENTITY, "organization_id": "org_NEW"}, ) @@ -2613,17 +2652,19 @@ def test_a_new_profile_name_that_belongs_to_a_third_organisation_is_confirmed( def test_login_overwrites_when_a_new_profile_is_declined(capsys, login_seams, tmp_path): - login_seams([PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"}) + login_seams(["", "", PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"}) run(capsys, "auth", "login") seams = login_seams( - [PK, "", ""], confirm=False, whoami={**IDENTITY, "organization_id": "org_NEW"} + ["", "", PK, "", ""], + confirm=False, + whoami={**IDENTITY, "organization_id": "org_NEW"}, ) code, out, _ = run(capsys, "auth", "login") assert code == int(ExitCode.SUCCESS) assert envelope(out)["data"]["profile"] == "cloud-us" - assert len(seams["prompts"]) == 3 + assert len(seams["prompts"]) == 5 assert 'org_id = "org_NEW"' in _written(tmp_path) assert "org_OLD" not in _written(tmp_path) @@ -2643,7 +2684,7 @@ def test_login_drops_the_keys_a_host_change_leaves_unchecked( login that stores another one would send them somewhere they were never accepted.""" (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - seams = login_seams([PK, "", ""], confirm=True) + seams = login_seams(["", "", PK, "", ""], confirm=True) code, _, _ = run(capsys, "auth", "--base-url", "https://moved.example/", "login") @@ -2663,7 +2704,7 @@ def test_login_aborts_rather_than_drop_keys_the_answer_declined( """Declining is a decision about the whole login: the keys are worth more than the host change, so nothing is written at all.""" (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - login_seams([PK, "", ""], confirm=False) + login_seams(["", "", PK, "", ""], confirm=False) code, _, _ = run(capsys, "auth", "--base-url", "https://moved.example/", "login") @@ -2770,6 +2811,92 @@ def test_a_host_that_differs_beyond_spelling_still_strands_the_keys( assert "deployment invoices" in err +def test_a_host_typed_at_the_prompt_is_the_one_checked_and_stored( + capsys, login_seams, tmp_path +): + """The on-premises path: Enter keeps the host the run resolved, anything + typed replaces it for the check and for the profile.""" + seams = login_seams(["https://onprem.example/", "", PK, "", ""]) + + code, _, _ = run(capsys, "auth", "login") + + assert code == int(ExitCode.SUCCESS) + assert seams["defaults"][:2] == [ + "https://us-central.unstract.com", + "https://llmwhisperer-api.us-central.unstract.com/api/v2", + ] + assert seams["platform"].built_with["base_url"] == "https://onprem.example/" + assert 'base_url = "https://onprem.example/"' in _written(tmp_path) + + +def test_the_host_prompt_offers_the_profile_s_own_host(capsys, login_seams, tmp_path): + (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") + seams = login_seams(["", "", PK, "", ""]) + + code, _, _ = run(capsys, "auth", "login") + + assert code == int(ExitCode.SUCCESS) + assert seams["defaults"][0] == "https://stored.example/" + assert seams["confirms"] == [] + + +def test_a_host_typed_at_the_prompt_is_stored_without_a_key_for_it( + capsys, login_seams, tmp_path +): + """A self-hosted LLMWhisperer whose key comes later: the host typed now must + not depend on a key being typed with it.""" + login_seams(["", "https://whisper.example/", PK, "", ""]) + + code, _, _ = run(capsys, "auth", "login") + + assert code == int(ExitCode.SUCCESS) + text = _written(tmp_path) + assert "[profiles.cloud-us.llmwhisperer]" in text + assert 'base_url = "https://whisper.example/"' in text + + +def test_the_host_prompt_refuses_anything_but_a_url(capsys, login_seams, tmp_path): + login_seams(["onprem.example", "", "", "", DK, ""]) + + code, _, err = run(capsys, "auth", "login") + + assert code == int(ExitCode.SUCCESS) + assert "'onprem.example' is not an http(s) URL" in err + assert 'base_url = "onprem.example"' not in _written(tmp_path) + + +def test_enter_at_the_host_prompt_keeps_a_profile_s_variable_reference( + capsys, login_seams, tmp_path, monkeypatch +): + monkeypatch.setenv("MY_HOST", "https://stored.example/") + (tmp_path / "config.toml").write_text( + STRANDING_CONFIG.replace('"https://stored.example/"', '"env:MY_HOST"'), + encoding="utf-8", + ) + seams = login_seams(["", "", PK, "", ""]) + + code, _, _ = run(capsys, "auth", "login") + + assert code == int(ExitCode.SUCCESS) + assert seams["defaults"][0] == "https://stored.example/" + assert 'base_url = "env:MY_HOST"' in _written(tmp_path) + + +def test_a_host_typed_at_the_prompt_goes_through_the_stranding_guard( + capsys, login_seams, tmp_path +): + (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") + seams = login_seams(["https://moved.example/", "", PK, "", ""], confirm=True) + + code, _, _ = run(capsys, "auth", "login") + + assert code == int(ExitCode.SUCCESS) + assert "deployment invoices" in seams["confirms"][0] + text = _written(tmp_path) + assert 'base_url = "https://moved.example/"' in text + assert "ENTRY-KEY-BBBB" not in text + + def test_login_writes_the_profile_named_and_checks_against_its_own_host( capsys, login_seams, tmp_path ): diff --git a/tests/test_config.py b/tests/test_config.py index 9dee8dd..ad0cb55 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -123,6 +123,19 @@ def test_require_names_every_way_to_supply_the_setting(): assert "--api-key" not in message +def test_a_missing_setting_points_a_fresh_install_at_login(write_config): + """With no config file at all the hints name a file the reader has never + seen; `auth login` writes it. Once a file exists the hint would only + repeat what the file already shows.""" + with pytest.raises(ConfigError, match="run `unstract auth login`"): + resolved().require(DOCSTUDIO, "api_key") + + write_config('default_profile = "p"\n\n[profiles.p.docstudio]\norg_id = "x"\n') + with pytest.raises(ConfigError) as excinfo: + resolved().require(DOCSTUDIO, "api_key") + assert "auth login" not in str(excinfo.value) + + def test_placeholder_is_not_a_value(write_config): """`config init` writes `org_id = ""`, and that must not satisfy `require`.""" write_config('default_profile = "p"\n\n[profiles.p.docstudio]\norg_id = ""\n')