From b3bc2161467ee02a0482b78ad47544e00b1a7b3b Mon Sep 17 00:00:00 2001 From: Ryan Min Date: Mon, 10 Aug 2026 10:40:05 +0900 Subject: [PATCH] fix(cli): say the approval window closed instead of reporting a raw 410 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A device request nobody confirms expires on schedule, which is the request doing what it promised. Surfacing the server's 410 as "REQUEST_REJECTED ... status 410" reads like a fault and sends whoever is looking at it hunting for one — it cost time in this session. The answer is always the same: run login again, so the CLI says that. Exit 5 keeps its contract meaning, a refusal on the merits rather than a transport problem, which is what an expiry is. Co-Authored-By: Claude Opus 5 --- .../idea2strategy/cli/Idea2StrategyCli.java | 16 +++++++++++- .../cli/Idea2StrategyCliTest.java | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java index fa0ed4e6..28c888cd 100644 --- a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java +++ b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java @@ -156,7 +156,21 @@ private static JsonNode browserLogin(Arguments args, ApiClient api, CredentialSt // the blank check below is the wait. Denied, expired, and unknown all arrive as // failures and propagate: none of them will ever turn into an approval, and polling on // would just burn the deadline. - JsonNode response = api.post("/api/v1/auth/device/token", poll, null); + JsonNode response; + try { + response = api.post("/api/v1/auth/device/token", poll, null); + } catch (CliFailure failure) { + // Nobody approved in time. Reporting the raw 410 leaves a person reading + // "REQUEST_REJECTED ... status 410" and looking for a fault; the request did + // exactly what it promised, and the answer is to run login again. + if (failure.status() != null && failure.status() == 410) { + throw new CliFailure( + 5, + "DEVICE_AUTHORIZATION_EXPIRED", + "The approval window closed before the code was confirmed. Run login again."); + } + throw failure; + } String token = response.path("accessToken").asText(); if (token.isBlank()) { continue; diff --git a/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java b/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java index 23003ab4..73986891 100644 --- a/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java +++ b/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java @@ -45,6 +45,31 @@ void stopServer() { server.stop(0); } + /** + * A device request that nobody confirmed in time is not a fault, and saying "REQUEST_REJECTED + * ... status 410" sends the reader looking for one. The answer is to run login again, so the + * CLI has to say that. + */ + @Test + void browserLoginExplainsAnApprovalWindowThatClosed() throws Exception { + server.removeContext("/"); + server.createContext("/api/v1/auth/device/authorize", exchange -> respond(exchange, 201, + "{\"deviceCode\":\"device-1\",\"userCode\":\"ABCD-EFGH\"," + + "\"verificationUriComplete\":\"https://example.test/cli-auth?code=ABCD-EFGH\"," + + "\"intervalSeconds\":1}")); + server.createContext("/api/v1/auth/device/token", exchange -> respond(exchange, 410, + "{\"error\":\"expired_token\"}")); + + Result result = run("", "--base-url", baseUrl, "--config-dir", tempDir.toString(), + "login", "--browser", "--no-open"); + + assertThat(result.exitCode()).isEqualTo(5); + assertThat(JSON.readTree(result.stderr()).path("error").path("code").asText()) + .isEqualTo("DEVICE_AUTHORIZATION_EXPIRED"); + assertThat(JSON.readTree(result.stderr()).path("error").path("message").asText()) + .contains("Run login again"); + } + @Test void loginReadsPasswordFromStandardInputAndStoresTokenWithoutEchoingSecrets() throws Exception { server.removeContext("/");