diff --git a/client/granthi_sync_client.py b/client/granthi_sync_client.py index 02a8ce9..00a49f1 100644 --- a/client/granthi_sync_client.py +++ b/client/granthi_sync_client.py @@ -599,8 +599,13 @@ def device_flow(): form={"client_id": DEVICE_CLIENT_ID, "scope": DEVICE_SCOPE}) if status != 200: raise SystemExit(f"device authorization failed (HTTP {status}): {resp}") - print(f"\nTo link this device, open:\n\n {resp.get('verification_uri_complete') or resp.get('verification_uri')}\n") - print(f"and enter code: {resp['user_code']}\n") + # flush=True is not cosmetic. Python buffers stdout when it is not a + # terminal, so `granthi-sync link | tee setup.log`, a wrapper script, or + # anything capturing output shows NOTHING while the code silently expires + # five minutes later. Hit for real on 2026-08-23 driving a first sign-in. + print(f"\nTo link this device, open:\n\n {resp.get('verification_uri_complete') or resp.get('verification_uri')}\n", + flush=True) + print(f"and enter code: {resp['user_code']}\n", flush=True) interval = int(resp.get("interval", 5)) deadline = time.time() + int(resp.get("expires_in", 300)) while time.time() < deadline: diff --git a/tests/test_client.py b/tests/test_client.py index 09731d3..fc732ee 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1284,5 +1284,31 @@ class TestResolveGranted(unittest.TestCase): client.resolve_granted(self.cfg, "Nirlabinc/Ai-Assistant"), "Nirlabinc/Ai-Assistant") + +class TestDeviceFlowOutputIsVisible(unittest.TestCase): + """The code has a five-minute life. If it is sitting in a buffer, the user + never sees it and it expires -- which is what happened on 2026-08-23 while + driving a first real sign-in through a wrapper.""" + + def test_the_url_and_code_are_flushed_immediately(self): + seen = [] + real_print = print + + def spy(*a, **kw): + seen.append(kw.get("flush", False)) + return real_print(*a, **{k: v for k, v in kw.items() if k != "flush"}) + + resp = {"verification_uri_complete": "https://id.example/device?user_code=AB-CD", + "user_code": "AB-CD", "device_code": "dc", "interval": 0, + "expires_in": 0} + with mock.patch.object(client, "http_json", lambda *a, **k: (200, resp)), \ + mock.patch("builtins.print", spy), \ + mock.patch.object(client.time, "sleep", lambda *_: None): + with self.assertRaises(SystemExit): # expires_in 0 -> times out + client.device_flow() + self.assertTrue(seen, "device_flow printed nothing") + self.assertTrue(all(seen[:2]), + "the URL and code must be printed with flush=True") + if __name__ == "__main__": unittest.main()