From 3fe4ab85b1082d34d40a6cec00e71f8446a66d5a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 13 Aug 2026 07:35:54 +0200 Subject: [PATCH 1/2] fix(cli): exit non-zero from whoami and login when unauthenticated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `veryfront whoami` printed "✗ Not logged in" and exited 0. The exit code is the machine-readable contract: CI steps, shell scripts, and agents gate on `veryfront whoami` to confirm auth, believe they are authenticated, and then fail later somewhere unrelated and much harder to diagnose. The router awaited `whoami()` and discarded its return value, so the null "no usable credential" result never reached the process exit code. `login` had the same shape — it returns null when it cannot obtain a credential and the router dropped that too. Both now exit 1. This repo's CLI reserves 2 for invalid usage (see the exit code table in the CLI output style guide), so "no usable credential" is a general failure, code 1. The human-readable output is unchanged: it still names the problem and points at `veryfront login`. `--json` still emits the `authenticated: false` envelope, now alongside a non-zero exit. Covered by subprocess tests that drive the real CLI entry point and assert on the process exit code itself, including a positive case behind a stub API so the fix cannot degenerate into always exiting 1. --- cli/auth/exit-code.integration.test.ts | 105 +++++++++++++++++++++++++ cli/commands/login/command-help.ts | 1 + cli/commands/whoami/command-help.ts | 1 + cli/router.ts | 6 +- 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 cli/auth/exit-code.integration.test.ts diff --git a/cli/auth/exit-code.integration.test.ts b/cli/auth/exit-code.integration.test.ts new file mode 100644 index 0000000000..73d07238cb --- /dev/null +++ b/cli/auth/exit-code.integration.test.ts @@ -0,0 +1,105 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { fromFileUrl } from "#veryfront/compat/path/index.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { makeTempDir, remove } from "#veryfront/platform/compat/fs.ts"; + +/** + * Exit codes are the machine-readable contract for the auth commands: CI steps, + * shell scripts, and agents gate on them. These tests drive the real CLI entry + * point in a subprocess so the assertion is on the process exit code itself. + */ +describe("cli/auth exit codes", { sanitizeOps: false, sanitizeResources: false }, () => { + const cliPath = fromFileUrl(new URL("../main.ts", import.meta.url)); + const configPath = fromFileUrl(new URL("../../deno.json", import.meta.url)); + + /** + * Runs the CLI with no usable credential: an empty `VERYFRONT_API_TOKEN` and a + * throwaway `XDG_CONFIG_HOME` so the developer's stored token is never read. + * The cwd is a temp directory so a repository `.env` cannot supply a token. + */ + async function runUnauthenticated( + args: string[], + ): Promise<{ code: number; stdout: string; stderr: string }> { + const tempDir = await makeTempDir({ prefix: "cli-auth-exit-code-" }); + try { + const result = await new Deno.Command(Deno.execPath(), { + args: ["run", "-A", "--config", configPath, cliPath, ...args], + cwd: tempDir, + env: { + VERYFRONT_API_TOKEN: "", + XDG_CONFIG_HOME: `${tempDir}/config`, + VERYFRONT_NO_UPDATE_CHECK: "1", + NO_COLOR: "1", + CI: "1", + }, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + const decoder = new TextDecoder(); + + return { + code: result.code, + stdout: decoder.decode(result.stdout), + stderr: decoder.decode(result.stderr), + }; + } finally { + await remove(tempDir, { recursive: true }); + } + } + + it("whoami exits non-zero when no credential is available", async () => { + const result = await runUnauthenticated(["whoami"]); + + assertEquals(result.code, 1); + assertStringIncludes(result.stdout, "Not logged in"); + assertStringIncludes(result.stdout, "veryfront login"); + }); + + it("whoami --json exits non-zero and still reports authenticated: false", async () => { + const result = await runUnauthenticated(["whoami", "--json"]); + + assertEquals(result.code, 1); + assertEquals(JSON.parse(result.stdout).data, { authenticated: false }); + }); + + it("login exits non-zero when it cannot obtain a credential", async () => { + const result = await runUnauthenticated(["login"]); + + assertEquals(result.code, 1); + }); + + it("whoami still exits zero when a credential validates", async () => { + const server = Deno.serve( + { port: 0, onListen: () => {} }, + () => Response.json({ id: "user-123", email: "cli@example.test" }), + ); + const baseUrl = `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`; + const tempDir = await makeTempDir({ prefix: "cli-auth-exit-code-ok-" }); + + try { + const result = await new Deno.Command(Deno.execPath(), { + args: ["run", "-A", "--config", configPath, cliPath, "whoami"], + cwd: tempDir, + env: { + VERYFRONT_API_TOKEN: "user-session-token", + VERYFRONT_API_BASE_URL: baseUrl, + XDG_CONFIG_HOME: `${tempDir}/config`, + VERYFRONT_NO_UPDATE_CHECK: "1", + NO_COLOR: "1", + CI: "1", + }, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + + assertEquals(result.code, 0); + assertStringIncludes(new TextDecoder().decode(result.stdout), "cli@example.test"); + } finally { + await server.shutdown(); + await remove(tempDir, { recursive: true }); + } + }); +}); diff --git a/cli/commands/login/command-help.ts b/cli/commands/login/command-help.ts index f613c7270d..785255619b 100644 --- a/cli/commands/login/command-help.ts +++ b/cli/commands/login/command-help.ts @@ -34,5 +34,6 @@ export const loginHelp: CommandHelp = { "Without options, prompts for authentication method", "OAuth methods open browser for authentication", "Token is stored in ~/.config/veryfront/token", + "Exits 1 when no credential was obtained, so scripts can gate on it", ], }; diff --git a/cli/commands/whoami/command-help.ts b/cli/commands/whoami/command-help.ts index 816489d3dc..6fa3e46cf2 100644 --- a/cli/commands/whoami/command-help.ts +++ b/cli/commands/whoami/command-help.ts @@ -10,5 +10,6 @@ export const whoamiHelp: CommandHelp = { notes: [ "Shows the authenticated user or API-key credential type", "Checks both environment variable and stored token", + "Exits 0 when a credential validates and 1 when none does, so scripts can gate on it", ], }; diff --git a/cli/router.ts b/cli/router.ts index db003e0e42..db32c875e5 100644 --- a/cli/router.ts +++ b/cli/router.ts @@ -71,7 +71,8 @@ const commands: Record = { return; } const { login } = await import("./auth/index.ts"); - await login(parseLoginMethod(args)); + // Exit non-zero so scripts can tell a failed login from a successful one. + if (!await login(parseLoginMethod(args))) exitProcess(1); }, "logout": async () => async (args) => { const { parseProvider } = await import("./auth/utils.ts"); @@ -90,7 +91,8 @@ const commands: Record = { }, "whoami": async () => async () => { const { whoami } = await import("./auth/index.ts"); - await whoami(); + // The exit code is the machine-readable answer: 0 authenticated, 1 not. + if (!await whoami()) exitProcess(1); }, "install": async () => (await import("./commands/install/handler.ts")).handleInstallCommand, "uninstall": async () => (await import("./commands/install/handler.ts")).handleUninstallCommand, From 2037c5f30ab12502e7252c0a6a5360692599a33c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 13 Aug 2026 08:35:17 +0200 Subject: [PATCH 2/2] fix(cli): exit non-zero from provider logins; drop needless sanitizer opt-outs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the same contract. `login --provider anthropic|openai` had the exact defect this PR set out to fix: both provider functions return Promise and the router discarded the result before reaching the exit guard, so an empty key, an invalid key, or a validation network error still exited 0 — contradicting the help text this PR added. Both branches now exit 1, so every login shape reports failure the same way. The new integration suite also carried sanitizeOps/sanitizeResources: false, which pushed the sanitizer ratchet from 404 to 406 and failed lint. The suite leaks nothing — every subprocess is awaited to completion, the stub server is shut down, and the temp dirs are removed — so the opt-outs are deleted rather than the baseline raised. The suite passes with both sanitizers enabled. The provider branches are deliberately not covered by a subprocess test: promptPassword calls Deno.stdin.setRaw(), which throws ENODEV on a non-TTY stdin, so such a test would exit 1 from that crash rather than from the failure path and would pass with this fix reverted. Noted in the test file. --- cli/auth/exit-code.integration.test.ts | 7 ++++++- cli/router.ts | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cli/auth/exit-code.integration.test.ts b/cli/auth/exit-code.integration.test.ts index 73d07238cb..f76196acae 100644 --- a/cli/auth/exit-code.integration.test.ts +++ b/cli/auth/exit-code.integration.test.ts @@ -9,7 +9,7 @@ import { makeTempDir, remove } from "#veryfront/platform/compat/fs.ts"; * shell scripts, and agents gate on them. These tests drive the real CLI entry * point in a subprocess so the assertion is on the process exit code itself. */ -describe("cli/auth exit codes", { sanitizeOps: false, sanitizeResources: false }, () => { +describe("cli/auth exit codes", () => { const cliPath = fromFileUrl(new URL("../main.ts", import.meta.url)); const configPath = fromFileUrl(new URL("../../deno.json", import.meta.url)); @@ -70,6 +70,11 @@ describe("cli/auth exit codes", { sanitizeOps: false, sanitizeResources: false } assertEquals(result.code, 1); }); + // `login --provider anthropic|openai` also exits 1 on failure (see cli/router.ts), + // but it cannot be driven from here: `promptPassword` calls `Deno.stdin.setRaw()`, + // which throws ENODEV on a non-TTY stdin. A subprocess test would exit 1 from that + // crash rather than from the failure path, and would pass with the fix reverted. + it("whoami still exits zero when a credential validates", async () => { const server = Deno.serve( { port: 0, onListen: () => {} }, diff --git a/cli/router.ts b/cli/router.ts index db32c875e5..41ba9223b7 100644 --- a/cli/router.ts +++ b/cli/router.ts @@ -60,18 +60,19 @@ const commands: Record = { "login": async () => async (args) => { const { parseLoginMethod, parseProvider } = await import("./auth/utils.ts"); const provider = parseProvider(args); + // Every branch reports failure the same way: exit non-zero so scripts can + // tell a failed login from a successful one, whichever credential was asked for. if (provider === "anthropic") { const { loginAnthropic } = await import("./auth/providers/anthropic.ts"); - await loginAnthropic(); + if (!await loginAnthropic()) exitProcess(1); return; } if (provider === "openai") { const { loginOpenAI } = await import("./auth/providers/openai.ts"); - await loginOpenAI(args["base-url"] as string | undefined); + if (!await loginOpenAI(args["base-url"] as string | undefined)) exitProcess(1); return; } const { login } = await import("./auth/index.ts"); - // Exit non-zero so scripts can tell a failed login from a successful one. if (!await login(parseLoginMethod(args))) exitProcess(1); }, "logout": async () => async (args) => {