diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db3fa7..7b9bb40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -561,6 +561,10 @@ called out explicitly under **Changed** or **Removed**. a stale warning that step ids rotate on every update is gone. - **The variable-condition docs no longer call `client_tag_field_id` a constant.** It varies by business. The docs say where to get it. +- **`kizen init` no longer prints the stored API key when it re-prompts for an + existing profile.** The prompt showed the key as its default, so it landed in + scrollback, screen shares and recordings. It now reads `API_KEY (Enter keeps + the stored key):`, and pressing Enter still keeps it. - **`activate`, `deactivate`, `move`, `steps` and `roundtrip --execute` no longer fail with HTTP 400 on automations with an Email Received trigger or diff --git a/src/kizen_builder/cli/init.py b/src/kizen_builder/cli/init.py index 30650ff..e775343 100644 --- a/src/kizen_builder/cli/init.py +++ b/src/kizen_builder/cli/init.py @@ -174,8 +174,14 @@ def _ask( "no default" sentinel is Ellipsis, not `None` or `""`, so the `default` kwarg is omitted rather than passed through — passing `None` would make Rich hand back `None` on a bare Enter, bypassing `choices` entirely. + + A `password` default is never shown: Rich prints the default in the + prompt even when it hides the typed input, which would put a stored + secret in scrollback. Bare Enter still returns it. """ prompt_cls = _EnvironmentPrompt if choices else Prompt + if password and default: + label = f"{label} [prompt.default](Enter keeps the stored key)[/]" try: if default is None: return prompt_cls.ask( @@ -187,6 +193,7 @@ def _ask( password=password, choices=choices, case_sensitive=False, + show_default=not password, ) except EOFError: if default: diff --git a/tests/test_cli.py b/tests/test_cli.py index 10642be..1bdd38a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2678,6 +2678,51 @@ def test_init_banner_shows_tagline_only_on_tiny_terminal(monkeypatch, tmp_path): assert profiles.get_profile("banner-tiny") is not None +@pytest.mark.parametrize("stdin", ["\n\n\n\n", ""], ids=["bare-enter", "eof"]) +def test_init_reprompt_hides_stored_api_key(monkeypatch, tmp_path, stdin): + """Rich prints a prompt's default even with `password=True`, so re-running + `init` used to show the stored key in the terminal.""" + from kizen_builder import config, profiles + + config.set_profile_override(None) + profiles.write_profile( + profiles.ProfileCreds( + name="delta", + api_key="FAKEKEY123", + business_id="DDDD", + user_id="user1", + base_url="https://app.go.kizen.com", + ) + ) + monkeypatch.chdir(tmp_path) + result = runner.invoke( + cli.app, ["init", "--profile", "delta", "--skip-validation"], input=stdin + ) + assert result.exit_code == 0, result.output + assert "FAKEKEY123" not in result.output + assert "Y123" not in result.output + assert "API_KEY (Enter keeps the stored key):" in result.output + assert "BUSINESS_ID (DDDD):" in result.output + + stored = profiles.get_profile("delta") + assert stored is not None and stored.api_key == "FAKEKEY123" + + +def test_init_api_key_prompt_has_no_empty_default(monkeypatch, tmp_path): + from kizen_builder import config + + config.set_profile_override(None) + monkeypatch.chdir(tmp_path) + result = runner.invoke( + cli.app, + ["init", "--profile", "epsilon", "--skip-validation"], + input="apikey\nEEEE\nuser1\ngo\n", + ) + assert result.exit_code == 0, result.output + assert "API_KEY: " in result.output + assert "API_KEY ()" not in result.output + + def test_init_environment_picker_resolves_named_host(monkeypatch, tmp_path): from kizen_builder import config, profiles