Skip to content

Commit 4e89fb7

Browse files
committed
v0.1.1 — fix invalid-token persistence + non-TTY hang + config-dir override
Two bugs surfaced by Rook 2026-05-10 during their full CLI E2E walk: 1. `mesh login` would write an invalid token to ~/.meshbook/config before /api/me verified it. /api/me is permissive (returns 200 with {authenticated: false} for unauth so the SPA can probe), so the bearer never raised an APIError — it landed on a "no user — odd" branch AFTER save_config had already persisted to disk. Fix: verify first against an in-memory test_cfg, only persist on success. Also detect the {authenticated: false} shape explicitly. 2. `mesh login` (no --token, piped stdin) would hang on Windows because getpass.getpass() blocks waiting for /dev/tty in non-TTY contexts. Fix: detect sys.stdin.isatty(), fall through to plain sys.stdin.readline() with a "(input will echo — non-TTY mode)" warning when stdin is a pipe. Tested with the three repro cases: --token w/ bad value (rejects, no persist), pipe stdin (reads, rejects format), /dev/null stdin (raises SystemExit cleanly). Defensive: config dir is now overridable via MESHBOOK_CONFIG_DIR or XDG_CONFIG_HOME. Legacy ~/.meshbook stays canonical when it exists. Tests: 9/9 passing, ruff clean.
1 parent a36d868 commit 4e89fb7

4 files changed

Lines changed: 137 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to `meshbook-cli` are documented here. The format follows [K
44

55
## [Unreleased]
66

7+
## [0.1.1] — 2026-05-10
8+
9+
### Fixed
10+
- **`mesh login` no longer persists invalid tokens.** Previously the token was written to `~/.meshbook/config` *before* `/api/me` verification, so an invalid `--token` left a dead credential on disk. Now the token is verified against the API in-memory first; only on success does it land on disk. Also detects the "200 + `authenticated:false`" shape `/api/me` returns for invalid bearers (it doesn't 401 — that's a SPA-friendly contract). Bug surfaced by Rook 2026-05-10 during their full CLI E2E walk.
11+
- **`mesh login` on non-TTY (piped stdin / CI / no terminal) no longer hangs.** `getpass.getpass()` blocks forever on Windows when stdin is a pipe with no `/dev/tty` fallback. Now we detect `sys.stdin.isatty()` and fall through to a plain `sys.stdin.readline()` with a "(input will echo — non-TTY mode)" warning. Same bug.
12+
- **Config dir resolution is now overridable.** Honour `MESHBOOK_CONFIG_DIR` env var (Pi users with read-only `$HOME` mounts), then `XDG_CONFIG_HOME` if exported, then the legacy `~/.meshbook` (which stays canonical for upgrade safety — if it already exists, we never silently migrate the user away from it).
13+
14+
### Added
15+
- 4 new tests covering the above (invalid-token-doesn't-persist, XDG honoured when no legacy dir, legacy dir takes precedence when present, explicit `MESHBOOK_CONFIG_DIR` wins). Pytest now 9/9 passing.
16+
717
## [0.1.0] — 2026-05-10
818

919
### Added

mesh/cli.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,29 @@
4949
import urllib.request
5050
from pathlib import Path
5151

52-
VERSION = "0.1.0"
52+
VERSION = "0.1.1"
5353
DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
54-
CONFIG_DIR = Path.home() / ".meshbook"
54+
55+
56+
def _resolve_config_dir() -> Path:
57+
"""Resolve the config directory. Honour `MESHBOOK_CONFIG_DIR` if set
58+
(lets a Pi user pin the dir under a writable mount), otherwise the
59+
XDG-style `$XDG_CONFIG_HOME/meshbook` if XDG_CONFIG_HOME is exported,
60+
otherwise the legacy dotfile `~/.meshbook`. The legacy path stays
61+
canonical for backward compat with v0.1.0 installs."""
62+
explicit = os.environ.get("MESHBOOK_CONFIG_DIR")
63+
if explicit:
64+
return Path(explicit).expanduser()
65+
legacy = Path.home() / ".meshbook"
66+
if legacy.exists():
67+
return legacy
68+
xdg = os.environ.get("XDG_CONFIG_HOME")
69+
if xdg:
70+
return Path(xdg).expanduser() / "meshbook"
71+
return legacy
72+
73+
74+
CONFIG_DIR = _resolve_config_dir()
5575
CONFIG_PATH = CONFIG_DIR / "config"
5676

5777

@@ -148,30 +168,69 @@ def _data(payload: dict) -> object:
148168
# ─── command implementations ───────────────────────────────────────────
149169

150170

171+
def _read_token_non_tty(prompt: str) -> str:
172+
"""Read a token from stdin without using getpass. `getpass.getpass`
173+
on Windows hangs indefinitely when stdin is a pipe (no /dev/tty
174+
fallback path); on POSIX it warns about echoed input. In non-TTY
175+
contexts (CI, automation, `printf $TOKEN | mesh login`) we'd
176+
rather just read the line cleanly."""
177+
print(prompt + " (input will echo — non-TTY mode)", file=sys.stderr, flush=True)
178+
line = sys.stdin.readline()
179+
if not line:
180+
raise SystemExit(
181+
"No token on stdin. Pass --token mb_token_… or run `mesh login` "
182+
"from a terminal."
183+
)
184+
return line.strip()
185+
186+
151187
def cmd_login(args, cfg: dict) -> int:
152188
base = args.base or cfg.get("base") or DEFAULT_BASE
153-
token = args.token
189+
token = (args.token or "").strip()
154190
if not token:
155191
print(f"Sign in to {base}")
156192
print("Get a token from /v2/#/account/api-tokens (mint and copy the plaintext).")
157-
token = getpass.getpass("Paste token: ").strip()
193+
if sys.stdin.isatty():
194+
token = getpass.getpass("Paste token: ").strip()
195+
else:
196+
token = _read_token_non_tty("Paste token:")
158197
if not token.startswith("mb_token_"):
159198
print("Token format looks off — should start with `mb_token_`.", file=sys.stderr)
160199
return 2
161-
cfg["base"] = base
162-
cfg["token"] = token
163-
save_config(cfg)
164-
# Verify by hitting /api/me
200+
201+
# Verify FIRST against an in-memory test cfg. We only persist the
202+
# config once the token has been confirmed by /api/me. Without this,
203+
# an invalid --token would still write to disk and the user's next
204+
# `mesh whoami` would silently use a dead credential.
205+
test_cfg = dict(cfg)
206+
test_cfg["base"] = base
207+
test_cfg["token"] = token
165208
try:
166-
me = _data(_api_call("GET", "/api/me", cfg=cfg))
209+
me = _data(_api_call("GET", "/api/me", cfg=test_cfg))
167210
except APIError as e:
168211
print(f"Token rejected: {e.message}", file=sys.stderr)
169212
return 1
213+
214+
# `/api/me` is permissive — it returns 200 with {authenticated: false}
215+
# for unauthenticated callers (so the SPA can probe "am I logged in?"
216+
# without a 401). A bearer that doesn't resolve to a valid user lands
217+
# on this branch, NOT on the APIError path. Detect it explicitly.
218+
if isinstance(me, dict) and me.get("authenticated") is False:
219+
print("Token rejected: /api/me reports authenticated=false — "
220+
"double-check you copied the full plaintext.", file=sys.stderr)
221+
return 1
170222
user = me.get("user") if isinstance(me, dict) else None
171223
if not user:
172224
print("Authenticated but /api/me returned no user — odd.", file=sys.stderr)
173225
return 1
174-
print(f"Signed in as @{user.get('username')} ({user.get('displayName')}, {user.get('identityType')})")
226+
227+
# Only NOW persist. Token is verified.
228+
cfg["base"] = base
229+
cfg["token"] = token
230+
save_config(cfg)
231+
232+
print(f"Signed in as @{user.get('username')} "
233+
f"({user.get('displayName')}, {user.get('identityType')})")
175234
print(f"Token saved to {CONFIG_PATH}")
176235
return 0
177236

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "meshbook-cli"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM."
99
readme = "README.md"
1010
license = "MIT"

tests/test_cli_smoke.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,60 @@ def test_corrupt_config_returns_empty(tmp_path, monkeypatch):
7676
cli.CONFIG_DIR.mkdir()
7777
cli.CONFIG_PATH.write_text("{ not valid json")
7878
assert cli.load_config() == {}
79+
80+
81+
def test_invalid_token_does_not_persist(tmp_path, monkeypatch):
82+
"""Bug Rook flagged 2026-05-10 — an invalid `--token` would write
83+
to disk before /api/me verification. The fix: verify against an
84+
in-memory test_cfg first, only persist on success."""
85+
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
86+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
87+
88+
# Stub out _api_call so it returns the "authenticated=false" shape
89+
# /api/me actually emits for an invalid bearer.
90+
def fake_api_call(method, path, *, cfg, **kw):
91+
return {"data": {"authenticated": False}}
92+
93+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
94+
95+
args = type("A", (), {"token": "mb_token_garbage", "base": None})()
96+
rc = cli.cmd_login(args, {})
97+
assert rc == 1, "cmd_login should fail on authenticated=false response"
98+
assert not cli.CONFIG_PATH.exists(), \
99+
"invalid token must NOT have been persisted to config"
100+
101+
102+
def test_xdg_config_home_honoured(monkeypatch, tmp_path):
103+
"""Defensive — if XDG_CONFIG_HOME is set AND ~/.meshbook doesn't
104+
exist, the CLI should write under XDG. Legacy ~/.meshbook stays
105+
canonical for upgrade safety."""
106+
fake_home = tmp_path / "home"
107+
fake_xdg = tmp_path / "xdg"
108+
fake_home.mkdir()
109+
monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
110+
monkeypatch.setenv("XDG_CONFIG_HOME", str(fake_xdg))
111+
monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
112+
resolved = cli._resolve_config_dir()
113+
# Legacy doesn't exist → XDG wins
114+
assert resolved == fake_xdg / "meshbook", resolved
115+
116+
117+
def test_legacy_config_dir_takes_precedence(monkeypatch, tmp_path):
118+
"""If `~/.meshbook` already exists from a prior install, keep
119+
using it — don't silently migrate the user's token away."""
120+
fake_home = tmp_path / "home"
121+
legacy = fake_home / ".meshbook"
122+
legacy.mkdir(parents=True)
123+
monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
124+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
125+
monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
126+
assert cli._resolve_config_dir() == legacy
127+
128+
129+
def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
130+
"""`MESHBOOK_CONFIG_DIR` overrides everything (Pi users w/
131+
read-only home directories pin to a writable mount)."""
132+
explicit = tmp_path / "explicit"
133+
monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
134+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
135+
assert cli._resolve_config_dir() == explicit

0 commit comments

Comments
 (0)