Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions cli/auth/exit-code.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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", () => {
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);
});

// `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: () => {} },
() => 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 });
}
});
});
1 change: 1 addition & 0 deletions cli/commands/login/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
kojiwakayama marked this conversation as resolved.
],
};
1 change: 1 addition & 0 deletions cli/commands/whoami/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
};
11 changes: 7 additions & 4 deletions cli/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,20 @@ const commands: Record<string, CommandLoader> = {
"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");
await login(parseLoginMethod(args));
if (!await login(parseLoginMethod(args))) exitProcess(1);
},
"logout": async () => async (args) => {
const { parseProvider } = await import("./auth/utils.ts");
Expand All @@ -90,7 +92,8 @@ const commands: Record<string, CommandLoader> = {
},
"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,
Expand Down