From 69d4edf37dcf6ea71904fcf491859b9c6dea4e9c Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 2 Jul 2026 02:34:16 -0400 Subject: [PATCH] fix: single-key TTY menu UX for the Gitea config menu (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the interactive profile menu feel like a real terminal menu, via a new injectable MenuIO abstraction (no menu logic change, no auth/secret-storage change). - Single-key top-level actions in a TTY (termios/tty raw read); no Enter needed. Non-TTY / test runs fall back to line input. - Enter backs out: Enter (or 0) on the main menu quits; Enter cancels any submenu/profile prompt and returns. - Profile chooser: everywhere a profile is needed, show a numbered list and pick by key (1-9), with an explicit 'm) type a name manually' path and Enter to cancel. Empty config handled gracefully. - Clear screen before redrawing the main menu and chooser — TTY only; never emits clear codes in non-TTY/test runs. - Result actions (validate/test-auth/whoami/eligibility) print a concise result then pause for a keypress in a TTY; non-TTY never blocks. Helpers: read_key (via default_io) / choose_menu_option / choose_profile / clear_screen / pause_for_key, plus MenuIO(is_tty, clear_enabled). TTY detected with sys.stdin.isatty() and sys.stdout.isatty(); stdlib only. Safety unchanged: no tokens/passwords printed, no raw config dumps, no .env.personal, no change to auth behavior or secret storage. Tests: rewrote menu tests around a scripted _FakeIO (no real terminal): single- key select + clear, main-menu Enter/0 quit, submenu Enter cancel (no change), chooser lists/selects/no-profiles/manual/out-of-range, non-TTY line fallback, clear-only-when-enabled, pause never hangs non-TTY, and add-flow proving the token value never reaches disk or stdout. Docs: runbook note on single-key nav / Enter back-out / numbered chooser. scripts/gitea-config-menu unchanged. Closes #36. Refs #31, #34. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/llm-workflow-runbooks.md | 5 + gitea_config_menu.py | 343 ++++++++++++++++++++++++++-------- tests/test_config_menu.py | 210 +++++++++++++++++---- 3 files changed, 439 insertions(+), 119 deletions(-) diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index b2a1acd..0628cd8 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -115,6 +115,11 @@ Menu options: list / add / edit / remove profiles · validate config · test profile authentication · show authenticated user · generate launcher snippets (Claude/Gemini/Codex) · check reviewer eligibility for a PR. +In a real terminal the menu takes a **single keypress** (no Enter), **Enter** +quits the main menu and cancels/back-outs of any submenu, and you pick a profile +from a **numbered list** instead of typing its name. Non-interactive runs +(pipes/tests) fall back to line input and never block. + **Create an author + a reviewer profile:** 1. `add profile` → name `prgs-author`, base URL, username, default owner/repo, diff --git a/gitea_config_menu.py b/gitea_config_menu.py index 69c02f1..d5c5c5b 100644 --- a/gitea_config_menu.py +++ b/gitea_config_menu.py @@ -1,15 +1,17 @@ -"""Interactive setup menu for the canonical Gitea MCP profile config (#31). +"""Interactive setup menu for the canonical Gitea MCP profile config (#31, #36). Create / edit / remove / validate profiles, test a profile's authentication, show the authenticated user, check reviewer eligibility for a PR, and print thin launcher snippets for Claude / Gemini / Codex — without hand-editing JSON and without ever putting a raw token in a launcher config. -Run: ``python gitea_config.py menu`` (or ``python gitea_config_menu.py``) +Run: ``./scripts/gitea-config-menu`` (or ``python gitea_config.py menu``). -Every side effect (stdin, secret prompt, stdout, keychain store, token -resolution, HTTP) is injectable so the menu logic is testable without a real -keychain, terminal, or network. +UX (#36): in an interactive TTY the top-level menu and profile chooser take a +single keypress (no Enter); Enter quits the main menu and cancels/back-outs of +any submenu; the screen clears before redraws; results pause for a keypress. +All of that runs through an injectable :class:`MenuIO`, so tests exercise the +logic with scripted keys and no real terminal, keychain, or network. """ import sys import json @@ -100,135 +102,320 @@ def launcher_snippets(profile_name, config_path): return {"claude": claude, "gemini": gemini, "codex": codex} -# ── Interactive prompts (thin wrappers over injected IO) ──────────────────────── +# ── Injectable menu IO ────────────────────────────────────────────────────────── -def _prompt_profile(input_fn, secret_fn, out, keychain_set): - """Collect a new/edited profile from the user. Returns (name, profile). +CLEAR_SEQUENCE = "\x1b[2J\x1b[H" - Tokens are read with the secret prompt (no echo) and stored in the keychain; - they are never placed in the returned profile. + +class MenuIO: + """Terminal IO for the menu, fully injectable for tests. + + - ``read_key(prompt)`` → one keypress in a TTY, or the first character of a + line otherwise. Enter / EOF return ``""`` (used as cancel/back/quit). + - ``read_line(prompt)`` → a full line (for free text). EOF returns ``""``. + - ``out(text)`` → write a line. + - ``clear()`` → clear the screen, but only when ``clear_enabled``. + + ``is_tty`` gates single-key input and the pause prompt; ``clear_enabled`` + gates screen clearing so automated/non-TTY runs never emit control codes. """ - name = input_fn("Profile name (e.g. prgs-reviewer): ").strip() - if not gitea_config.is_valid_profile_name(name): - raise gitea_config.ConfigError(f"invalid profile name {name!r}") - base_url = input_fn("Base URL (e.g. https://gitea.prgs.cc): ").strip() - username = input_fn("Gitea username: ").strip() - default_owner = input_fn("Default owner (optional): ").strip() - default_repo = input_fn("Default repo (optional): ").strip() - execution_profile = input_fn("Execution profile (optional): ").strip() - auth_type = input_fn("Auth type [keychain/env]: ").strip().lower() + + def __init__(self, *, read_key, read_line, write, is_tty=False, + clear_enabled=False, clear_fn=None): + self._read_key = read_key + self._read_line = read_line + self._write = write + self.is_tty = is_tty + self.clear_enabled = clear_enabled + self._clear_fn = clear_fn + + def read_key(self, prompt=""): + return self._read_key(prompt) + + def read_line(self, prompt=""): + return self._read_line(prompt) + + def out(self, text=""): + self._write(text) + + def clear(self): + if self.clear_enabled and self._clear_fn: + self._clear_fn() + + +def _read_key_tty(): + """Read a single keypress from a real TTY without requiring Enter. + + Returns ``""`` for Enter, raises KeyboardInterrupt on Ctrl-C. macOS/Linux + only (termios/tty); callers use a line-input fallback when not a TTY. + """ + import termios + import tty + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + if ch in ("\r", "\n", ""): + return "" + if ch == "\x03": + raise KeyboardInterrupt + return ch + + +def default_io(): + """Build the real MenuIO: single-key in a TTY, line-input otherwise.""" + is_tty = bool(getattr(sys.stdin, "isatty", lambda: False)()) and \ + bool(getattr(sys.stdout, "isatty", lambda: False)()) + + def write(text=""): + print(text) + + def read_key(prompt=""): + if prompt: + sys.stdout.write(prompt) + sys.stdout.flush() + if is_tty: + key = _read_key_tty() + print() # move off the prompt line + return key + line = sys.stdin.readline() + if line == "": + return "" # EOF → back/quit + return line.rstrip("\r\n")[:1] + + def read_line(prompt=""): + try: + return input(prompt) + except EOFError: + return "" + + def clear(): + sys.stdout.write(CLEAR_SEQUENCE) + sys.stdout.flush() + + return MenuIO(read_key=read_key, read_line=read_line, write=write, + is_tty=is_tty, clear_enabled=is_tty, clear_fn=clear) + + +# ── UX helpers ────────────────────────────────────────────────────────────────── + +_MENU_LINES = [ + "Gitea MCP profile setup", + " 1) list profiles", + " 2) add profile", + " 3) edit profile", + " 4) remove profile", + " 5) validate config", + " 6) test profile authentication", + " 7) show authenticated user", + " 8) generate launcher snippets (Claude/Gemini/Codex)", + " 9) check reviewer eligibility for a PR", + " (Enter or 0) quit", +] + + +def clear_screen(io): + """Clear the terminal (no-op unless the IO has clearing enabled).""" + io.clear() + + +def pause_for_key(io): + """Wait for a keypress in a TTY; return immediately otherwise (never hangs).""" + if io.is_tty: + io.out("Press any key to continue") + io.read_key() + + +def choose_menu_option(io): + """Clear, draw the main menu, and return the pressed key ('' = quit).""" + clear_screen(io) + for line in _MENU_LINES: + io.out(line) + return io.read_key("choice: ") + + +def choose_profile(io, config, purpose="profile"): + """Show a numbered profile list and return the chosen name, or None. + + Enter cancels (returns None). Digits 1-9 select from the list. 'm' opens an + explicit manual-entry path. Empty config is handled gracefully. + """ + names = sorted((config.get("profiles") or {})) + clear_screen(io) + if not names: + io.out("(no profiles yet — add one first)") + pause_for_key(io) + return None + io.out(f"Select {purpose} (Enter to cancel):") + for i, name in enumerate(names[:9], start=1): + io.out(f" {i}) {name}") + io.out(" m) type a name manually") + key = io.read_key("choose: ") + if key == "": + return None + if key == "m": + typed = io.read_line("profile name: ").strip() + return typed or None + if key.isdigit(): + idx = int(key) + if 1 <= idx <= min(len(names), 9): + return names[idx - 1] + io.out("invalid selection") + pause_for_key(io) + return None + + +def _prompt_profile_fields(io, secret_fn, keychain_set, name): + """Prompt for a profile's fields for *name*. Returns a profile dict or None. + + A blank Base URL cancels. Tokens are read with the secret prompt (no echo) + and stored in the keychain; they are never placed in the returned profile. + """ + base_url = io.read_line("Base URL (e.g. https://gitea.prgs.cc; Enter cancels): ").strip() + if not base_url: + return None + username = io.read_line("Gitea username: ").strip() + default_owner = io.read_line("Default owner (optional): ").strip() + default_repo = io.read_line("Default repo (optional): ").strip() + execution_profile = io.read_line("Execution profile (optional): ").strip() + auth_type = io.read_line("Auth type [keychain/env]: ").strip().lower() if auth_type == "keychain": default_id = f"{name}-gitea-token" - item_id = input_fn(f"Keychain item id [{default_id}]: ").strip() or default_id - store = input_fn("Store a token in the keychain now? [y/N]: ").strip().lower() + item_id = io.read_line(f"Keychain item id [{default_id}]: ").strip() or default_id + store = io.read_line("Store a token in the keychain now? [y/N]: ").strip().lower() if store == "y": token = secret_fn("Gitea token (hidden): ") keychain_set(item_id, token) # token never echoed - out(f"Stored token in keychain item '{item_id}'.") + io.out(f"Stored token in keychain item '{item_id}'.") auth = gitea_config.keychain_auth(item_id) elif auth_type == "env": default_var = f"GITEA_TOKEN_{name.upper().replace('-', '_').replace('.', '_')}" - var = input_fn(f"Env var name [{default_var}]: ").strip() or default_var - out(f"Set {var} in the environment; its value is never stored here.") + var = io.read_line(f"Env var name [{default_var}]: ").strip() or default_var + io.out(f"Set {var} in the environment; its value is never stored here.") auth = gitea_config.env_auth(var) else: raise gitea_config.ConfigError("auth type must be 'keychain' or 'env'") - profile = gitea_config.build_profile( + return gitea_config.build_profile( base_url=base_url, auth=auth, username=username, default_owner=default_owner, default_repo=default_repo, execution_profile=execution_profile, ) - return name, profile -_MENU = """ -Gitea MCP profile setup - 1) list profiles - 2) add profile - 3) edit profile - 4) remove profile - 5) validate config - 6) test profile authentication - 7) show authenticated user - 8) generate launcher snippets (Claude/Gemini/Codex) - 9) check reviewer eligibility for a PR - 0) quit -""" - - -def main(argv=None, *, input_fn=input, secret_fn=None, out=None, config_path=None, +def main(argv=None, *, io=None, secret_fn=None, config_path=None, resolve_token=gitea_config.resolve_token, api_request=gitea_auth.api_request, keychain_set=gitea_config.keychain_set): """Run the interactive menu loop. Returns a process exit code.""" + io = io or default_io() secret_fn = secret_fn or getpass.getpass - out = out or (lambda *a: print(*a)) path = config_path or gitea_config.config_path() or gitea_config.DEFAULT_CONFIG_PATH try: config = gitea_config.load_config(path) or gitea_config.empty_config() except gitea_config.ConfigError as exc: - out(f"config error: {exc}") + io.out(f"config error: {exc}") config = gitea_config.empty_config() while True: - out(_MENU) - choice = input_fn("choice: ").strip() + key = choose_menu_option(io) + if key in ("", "0"): # Enter or 0 → quit + io.out("bye") + return 0 try: - if choice == "0": - out("bye") - return 0 - elif choice == "1": + if key == "1": names = sorted(config.get("profiles") or {}) - out("profiles: " + (", ".join(names) if names else "(none)")) - elif choice == "2": - name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set) + clear_screen(io) + io.out("profiles: " + (", ".join(names) if names else "(none)")) + pause_for_key(io) + elif key == "2": + name = io.read_line("New profile name (Enter to cancel): ").strip() + if not name: + continue + if not gitea_config.is_valid_profile_name(name): + io.out(f"invalid profile name {name!r}") + pause_for_key(io) + continue + profile = _prompt_profile_fields(io, secret_fn, keychain_set, name) + if profile is None: + continue config = gitea_config.add_profile(config, name, profile) saved = gitea_config.save_config(config, path) - out(f"added '{name}'; wrote {saved}") - elif choice == "3": - name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set) + io.out(f"added '{name}'; wrote {saved}") + pause_for_key(io) + elif key == "3": + name = choose_profile(io, config, "profile to edit") + if not name: + continue + profile = _prompt_profile_fields(io, secret_fn, keychain_set, name) + if profile is None: + continue config = gitea_config.upsert_profile(config, name, profile) saved = gitea_config.save_config(config, path) - out(f"saved '{name}'; wrote {saved}") - elif choice == "4": - name = input_fn("profile to remove: ").strip() + io.out(f"saved '{name}'; wrote {saved}") + pause_for_key(io) + elif key == "4": + name = choose_profile(io, config, "profile to remove") + if not name: + continue config = gitea_config.remove_profile(config, name) saved = gitea_config.save_config(config, path) - out(f"removed '{name}'; wrote {saved}") - elif choice == "5": + io.out(f"removed '{name}'; wrote {saved}") + pause_for_key(io) + elif key == "5": problems = gitea_config.validate_config(config) - out("valid" if not problems else "problems:\n " + "\n ".join(problems)) - elif choice in ("6", "7"): - name = input_fn("profile: ").strip() + clear_screen(io) + io.out("valid" if not problems + else "problems:\n " + "\n ".join(problems)) + pause_for_key(io) + elif key in ("6", "7"): + name = choose_profile(io, config, "profile") + if not name: + continue profile = gitea_config.select_profile(config, name) who = resolve_identity(profile, resolve_token=resolve_token, api_request=api_request) - out(f"authenticated as: {who}") - elif choice == "8": - name = input_fn("profile: ").strip() + io.out(f"authenticated as: {who}") + pause_for_key(io) + elif key == "8": + name = choose_profile(io, config, "profile") + if not name: + continue + clear_screen(io) for client, snippet in launcher_snippets(name, path).items(): - out(f"--- {client} ---\n{snippet}") - elif choice == "9": - name = input_fn("profile: ").strip() - pr_number = input_fn("PR number: ").strip() + io.out(f"--- {client} ---\n{snippet}") + pause_for_key(io) + elif key == "9": + name = choose_profile(io, config, "profile") + if not name: + continue + pr_number = io.read_line("PR number (Enter to cancel): ").strip() + if not pr_number: + continue profile = gitea_config.select_profile(config, name) result = check_eligibility( profile, pr_number, resolve_token=resolve_token, api_request=api_request) - out(f"authenticated user: {result['authenticated_user']}") - out(f"PR author: {result['pr_author']}") - out("ELIGIBLE" if result["eligible"] else "INELIGIBLE") + io.out(f"authenticated user: {result['authenticated_user']}") + io.out(f"PR author: {result['pr_author']}") + io.out("ELIGIBLE" if result["eligible"] else "INELIGIBLE") for r in result["reasons"]: - out(f" - {r}") + io.out(f" - {r}") + pause_for_key(io) else: - out("unknown choice") + io.out("unknown choice") + pause_for_key(io) except gitea_config.ConfigError as exc: - out(f"error: {exc}") + io.out(f"error: {exc}") + pause_for_key(io) except Exception as exc: # noqa: BLE001 - keep the menu alive, no secrets - out(f"error: {type(exc).__name__}") + io.out(f"error: {type(exc).__name__}") + pause_for_key(io) if __name__ == "__main__": # pragma: no cover diff --git a/tests/test_config_menu.py b/tests/test_config_menu.py index d1596f5..3af80c3 100644 --- a/tests/test_config_menu.py +++ b/tests/test_config_menu.py @@ -238,57 +238,185 @@ class TestIdentityAndEligibility(unittest.TestCase): # --------------------------------------------------------------------------- -# Prompt flow + full menu loop (no real IO/keychain/network) +# UX: single-key nav, Enter back-out, profile chooser, clear, pause (#36) # --------------------------------------------------------------------------- -class TestMenuFlow(unittest.TestCase): +class _FakeIO: + """Duck-typed MenuIO: scripted keys/lines, captured output, clear counter. - def test_prompt_keychain_stores_but_profile_has_no_token(self): - stored = {} - answers = ["prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3", - "Org", "Repo", "personal-prgs", "keychain", - "prgs-reviewer-token", "y"] - out = _Out() - name, profile = menu._prompt_profile( - _InputQueue(answers), secret_fn=lambda _p: SECRET, out=out, - keychain_set=lambda i, t: stored.update({i: t})) - self.assertEqual(name, "prgs-reviewer") - self.assertEqual(profile["auth"], {"type": "keychain", "id": "prgs-reviewer-token"}) - self.assertNotIn(SECRET, json.dumps(profile)) # token not in profile - self.assertNotIn(SECRET, out.text) # token not printed - self.assertEqual(stored, {"prgs-reviewer-token": SECRET}) # stored via keychain + read_key/read_line pop from their queues; when a queue is exhausted they + return "" (Enter/EOF → cancel/quit), so a script can never hang the loop. + """ - def test_prompt_env_reference(self): - answers = ["prgs-author", "https://gitea.prgs.cc", "jcwalker3", - "", "", "personal-prgs", "env", ""] # blank -> default var name - name, profile = menu._prompt_profile( - _InputQueue(answers), secret_fn=lambda _p: SECRET, out=_Out(), - keychain_set=lambda *a: None) - self.assertEqual(profile["auth"], {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"}) + def __init__(self, keys=(), lines=(), is_tty=False, clear_enabled=None): + self.keys = list(keys) + self.lines = list(lines) + self.out_lines = [] + self.clears = 0 + self.is_tty = is_tty + self.clear_enabled = is_tty if clear_enabled is None else clear_enabled - def test_menu_add_then_quit_writes_config_without_secrets(self): + def read_key(self, prompt=""): + return self.keys.pop(0) if self.keys else "" + + def read_line(self, prompt=""): + return self.lines.pop(0) if self.lines else "" + + def out(self, text=""): + self.out_lines.append(str(text)) + + def clear(self): + if self.clear_enabled: + self.clears += 1 + + @property + def text(self): + return "\n".join(self.out_lines) + + +def _cfg(*names): + cfg = gitea_config.empty_config() + for n in names: + cfg["profiles"][n] = {"base_url": "https://x", + "auth": gitea_config.env_auth("V")} + return cfg + + +class TestChooser(unittest.TestCase): + + def test_menu_single_key_and_clears(self): + io = _FakeIO(keys=["3"], is_tty=True) + self.assertEqual(menu.choose_menu_option(io), "3") # one keypress, no Enter + self.assertEqual(io.clears, 1) # cleared before draw + self.assertIn("Gitea MCP profile setup", io.text) + + def test_profile_chooser_lists_and_selects_by_key(self): + io = _FakeIO(keys=["2"]) + self.assertEqual(menu.choose_profile(io, _cfg("aaa", "bbb", "ccc")), "bbb") + self.assertIn("1) aaa", io.text) + self.assertIn("2) bbb", io.text) + + def test_profile_chooser_enter_cancels(self): + io = _FakeIO(keys=[""]) + self.assertIsNone(menu.choose_profile(io, _cfg("aaa"))) + + def test_profile_chooser_no_profiles(self): + io = _FakeIO(keys=[]) + self.assertIsNone(menu.choose_profile(io, gitea_config.empty_config())) + self.assertIn("no profiles", io.text) + + def test_profile_chooser_manual_entry(self): + io = _FakeIO(keys=["m"], lines=["typed-name"]) + self.assertEqual(menu.choose_profile(io, _cfg("aaa")), "typed-name") + + def test_profile_chooser_out_of_range_key(self): + io = _FakeIO(keys=["9"]) + self.assertIsNone(menu.choose_profile(io, _cfg("aaa"))) + self.assertIn("invalid selection", io.text) + + +class TestClearAndPause(unittest.TestCase): + + def test_clear_only_when_enabled(self): + off = _FakeIO(keys=["x"], clear_enabled=False) + menu.choose_menu_option(off) + self.assertEqual(off.clears, 0) + on = _FakeIO(keys=["x"], clear_enabled=True) + menu.choose_menu_option(on) + self.assertEqual(on.clears, 1) + + def test_pause_does_not_read_in_non_tty(self): + io = _FakeIO(keys=["a"], is_tty=False) + menu.pause_for_key(io) + self.assertEqual(io.keys, ["a"]) # nothing consumed, no hang + + def test_pause_reads_one_key_in_tty(self): + io = _FakeIO(keys=["a"], is_tty=True) + menu.pause_for_key(io) + self.assertEqual(io.keys, []) # consumed the keypress + + def test_default_io_non_tty_line_fallback(self): + import io as _io + with patch("gitea_config_menu.sys.stdin", _io.StringIO("7\nrest\n")): + mio = menu.default_io() + self.assertFalse(mio.is_tty) + self.assertEqual(mio.read_key(), "7") # first char of the line + + +class TestMenuNav(unittest.TestCase): + + def test_main_enter_quits(self): + with tempfile.TemporaryDirectory() as d: + io = _FakeIO(keys=[""]) # Enter at main menu + rc = menu.main(io=io, config_path=os.path.join(d, "p.json"), + secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None) + self.assertEqual(rc, 0) + self.assertIn("bye", io.text) + + def test_main_zero_quits(self): + with tempfile.TemporaryDirectory() as d: + io = _FakeIO(keys=["0"]) + self.assertEqual( + menu.main(io=io, config_path=os.path.join(d, "p.json"), + secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None), 0) + + def test_submenu_enter_cancels_without_change(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "p.json") + gitea_config.save_config(_cfg("keep-me"), path) + io = _FakeIO(keys=["4", ""]) # remove → chooser Enter cancels → quit + menu.main(io=io, config_path=path, + secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None) + with open(path, encoding="utf-8") as fh: + self.assertIn("keep-me", fh.read()) # not removed + + +class TestMenuAddFlow(unittest.TestCase): + + def test_add_then_quit_writes_config_without_secrets(self): with tempfile.TemporaryDirectory() as d: path = os.path.join(d, "profiles.json") - answers = [ - "2", # add profile - "prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3", - "Org", "Repo", "personal-prgs", "keychain", - "prgs-reviewer-token", "y", - "1", # list - "0", # quit - ] - out = _Out() - rc = menu.main( - input_fn=_InputQueue(answers), secret_fn=lambda _p: SECRET, - out=out, config_path=path, keychain_set=lambda *a: None) + io = _FakeIO( + keys=["2", ""], # add profile, then Enter → quit + lines=["prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3", + "Org", "Repo", "personal-prgs", "keychain", + "prgs-reviewer-token", "y"], + ) + rc = menu.main(io=io, config_path=path, secret_fn=lambda _p: SECRET, + keychain_set=lambda *a: None) self.assertEqual(rc, 0) - self.assertTrue(os.path.isfile(path)) with open(path, encoding="utf-8") as fh: body = fh.read() self.assertIn("prgs-reviewer", body) - self.assertIn("prgs-reviewer-token", body) # keychain id (non-secret) - self.assertNotIn(SECRET, body) # never the token value - self.assertNotIn(SECRET, out.text) - self.assertIn("prgs-reviewer", out.text) # listed + self.assertIn("prgs-reviewer-token", body) # keychain id (non-secret) + self.assertNotIn(SECRET, body) # token value never on disk + self.assertNotIn(SECRET, io.text) # nor printed + + def test_prompt_fields_keychain_no_token_leak(self): + stored = {} + io = _FakeIO(lines=["https://gitea.prgs.cc", "jcwalker3", "Org", "Repo", + "personal-prgs", "keychain", "prgs-reviewer-token", "y"]) + profile = menu._prompt_profile_fields( + io, secret_fn=lambda _p: SECRET, + keychain_set=lambda i, t: stored.update({i: t}), name="prgs-reviewer") + self.assertEqual(profile["auth"], {"type": "keychain", "id": "prgs-reviewer-token"}) + self.assertNotIn(SECRET, json.dumps(profile)) + self.assertNotIn(SECRET, io.text) + self.assertEqual(stored, {"prgs-reviewer-token": SECRET}) + + def test_prompt_fields_env_reference(self): + io = _FakeIO(lines=["https://gitea.prgs.cc", "jcwalker3", "", "", + "personal-prgs", "env", ""]) + profile = menu._prompt_profile_fields( + io, secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None, + name="prgs-author") + self.assertEqual(profile["auth"], + {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"}) + + def test_prompt_fields_blank_base_url_cancels(self): + io = _FakeIO(lines=[""]) # blank Base URL → cancel + self.assertIsNone(menu._prompt_profile_fields( + io, secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None, + name="x")) if __name__ == "__main__": -- 2.43.7