Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .changeset/browser-sign-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@qawolf/cli": minor
---

`qawolf auth login` now asks how you want to sign in. Choose `Browser` to sign in with your QA Wolf account, or `API key` to paste a team key as before. The browser option shows a short code, opens the verification page, and waits until you confirm the code. If the CLI cannot open a browser, it prints the URL for you to open.

Browser sign-in uses WorkOS Connect. The CLI reads the sign-in provider and the public client ID from the QA Wolf deployment it points at, so it works against any host that publishes them. The session it stores is bound to that deployment's API URL. A deployment that does not publish a WorkOS Connect configuration says so, and you can run the command again and choose the API key path.

The CLI keeps the session in the system keychain, and falls back to a file that only its owner can read. It refreshes the session automatically before the access token expires, and it keeps the session bound to the same deployment on every refresh. `qawolf auth logout` removes both the session and the API key. An API key still takes precedence over a browser session.

If you point the CLI at a different deployment, it does not reuse the session from the previous one. Sign in again for the new deployment.
2 changes: 1 addition & 1 deletion skills/qawolf-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ that `url`; never guess a route and never send a repository link in its place.
<!-- prettier-ignore -->
| Command | Kind | What it does |
| --- | --- | --- |
| `qawolf auth login` | local | Authenticate with your QA Wolf API key |
| `qawolf auth login` | local | Authenticate with QA Wolf in a browser or with an API key |
| `qawolf auth logout` | local | Remove stored credentials |
| `qawolf auth whoami` | read | Show authentication status |
| `qawolf automate` | write | Request automation for draft flows. First create a named local .flow.ts draft for every requested journey that does not already have a matching draft; never reuse a generic starter or placeholder. Each new draft must start with a JSDoc Goal: description, import flow from @qawolf/flows/web, and use export default flow(...); a comment-only file or direct test(...) call is not a valid draft. Commit and push all changes with Git to publish them, then list remote drafts to resolve every selected ID. Do not use patch to create or rename a selected flow. Finally make one automation request containing all requested flow IDs. |
Expand Down
2 changes: 1 addition & 1 deletion src/commands/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function registerAuthCommand(
.description("Manage authentication with QA Wolf");

declareCommandKind(auth.command("login"), "local")
.description("Authenticate with your QA Wolf API key")
.description("Authenticate with QA Wolf in a browser or with an API key")
.action(withContext(signals, handleLogin));

declareCommandKind(auth.command("logout"), "local")
Expand Down
187 changes: 187 additions & 0 deletions src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, mock } from "bun:test";

import type { ApiKeyResult } from "~/domains/auth/types.js";
import type { CommandContext } from "~/shell/commandContext.js";
import type { UI } from "~/shell/ui/types.js";
import { handleLogin } from "./login.js";

afterEach(() => {
mock.restore();
});

function makeCtx(
ui: Partial<UI> & { mode: UI["mode"] },
): CommandContext & { ui: UI } {
return {
ui: {
gap: mock(),
intro: mock(),
info: mock(),
warn: mock(),
cancel: mock(),
error: mock(),
...ui,
} as unknown as UI,
configDir: "/config",
} as unknown as CommandContext & { ui: UI };
}

function makeDeps(
overrides: {
resolveApiKey?: () => Promise<ApiKeyResult | undefined>;
} = {},
) {
return {
resolveApiKey: overrides.resolveApiKey ?? (async () => undefined),
loginWithApiKey: mock(async () => undefined),
loginWithDevice: mock(async () => undefined),
};
}

describe("handleLogin", () => {
it("refuses to run without an interactive terminal", async () => {
const ctx = makeCtx({ mode: "json" });
const deps = makeDeps();

const result = await handleLogin(ctx, deps);

expect(result).toEqual({ error: "non-interactive" });
expect(deps.loginWithDevice).not.toHaveBeenCalled();
expect(deps.loginWithApiKey).not.toHaveBeenCalled();
});

it("routes to browser sign-in when the browser option is chosen", async () => {
const ctx = makeCtx({
mode: "human",
select: mock(async () => ({ ok: true as const, value: "browser" })),
});
const deps = makeDeps();

await handleLogin(ctx, deps);

expect(deps.loginWithDevice).toHaveBeenCalledTimes(1);
expect(deps.loginWithApiKey).not.toHaveBeenCalled();
});

it("routes to the API key prompt when that option is chosen", async () => {
const ctx = makeCtx({
mode: "human",
select: mock(async () => ({ ok: true as const, value: "api-key" })),
});
const deps = makeDeps();

await handleLogin(ctx, deps);

expect(deps.loginWithApiKey).toHaveBeenCalledTimes(1);
expect(deps.loginWithDevice).not.toHaveBeenCalled();
});

it("signs in with neither method when the choice is dismissed", async () => {
const ctx = makeCtx({
mode: "human",
select: mock(async () => ({ ok: false as const })),
});
const deps = makeDeps();

await handleLogin(ctx, deps);

expect(ctx.ui.cancel).toHaveBeenCalled();
expect(deps.loginWithApiKey).not.toHaveBeenCalled();
expect(deps.loginWithDevice).not.toHaveBeenCalled();
});

it("stops when an already-authenticated person declines to sign in again", async () => {
const ctx = makeCtx({
mode: "human",
confirm: mock(async () => ({ ok: true as const, value: false })),
select: mock(async () => ({ ok: true as const, value: "browser" })),
});
const deps = makeDeps({
resolveApiKey: async () => ({
key: "qaw_existing",
source: "env",
}),
});

await handleLogin(ctx, deps);

expect(deps.loginWithDevice).not.toHaveBeenCalled();
expect(ctx.ui.select).not.toHaveBeenCalled();
});

it("offers the choice when an already-authenticated person confirms", async () => {
const ctx = makeCtx({
mode: "human",
confirm: mock(async () => ({ ok: true as const, value: true })),
select: mock(async () => ({ ok: true as const, value: "browser" })),
});
const deps = makeDeps({
resolveApiKey: async () => ({
key: "qaw_existing",
source: "env",
}),
});

await handleLogin(ctx, deps);

expect(deps.loginWithDevice).toHaveBeenCalledTimes(1);
});
// The precedence itself is deliberate; being told "Signed in as ..." while
// every later command keeps using the old key is not.
it.each([
["env" as const, "unset the variable"],
["keychain" as const, "auth logout"],
["file" as const, "auth logout"],
])(
"warns before browser sign-in that a %s API key still wins",
async (source, remedy) => {
const ctx = makeCtx({
mode: "human",
confirm: mock(async () => ({ ok: true as const, value: true })),
select: mock(async () => ({ ok: true as const, value: "browser" })),
});
const deps = makeDeps({
resolveApiKey: async () => ({ key: "qaw_old", source }),
});

await handleLogin(ctx, deps);

expect(ctx.ui.warn).toHaveBeenCalledTimes(1);
expect(
(ctx.ui.warn as ReturnType<typeof mock>).mock.calls[0]?.[0],
).toContain(remedy);
expect(deps.loginWithDevice).toHaveBeenCalledTimes(1);
},
);

it("does not warn when the previous session was itself a browser one", async () => {
const ctx = makeCtx({
mode: "human",
confirm: mock(async () => ({ ok: true as const, value: true })),
select: mock(async () => ({ ok: true as const, value: "browser" })),
});
const deps = makeDeps({
resolveApiKey: async () => ({ key: "access_old", source: "browser" }),
});

await handleLogin(ctx, deps);

expect(ctx.ui.warn).not.toHaveBeenCalled();
});

it("does not warn on the API key path, where nothing is shadowed", async () => {
const ctx = makeCtx({
mode: "human",
confirm: mock(async () => ({ ok: true as const, value: true })),
select: mock(async () => ({ ok: true as const, value: "api-key" })),
});
const deps = makeDeps({
resolveApiKey: async () => ({ key: "qaw_old", source: "keychain" }),
});

await handleLogin(ctx, deps);

expect(ctx.ui.warn).not.toHaveBeenCalled();
expect(deps.loginWithApiKey).toHaveBeenCalledTimes(1);
});
});
97 changes: 52 additions & 45 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import {
resolveApiKey,
saveApiKey,
validateApiKey,
} from "~/domains/auth/index.js";
import { createPlatformClient } from "~/shell/platform/createPlatformClient.js";
import {
type CommandContext,
type CommandResult,
} from "~/shell/commandContext.js";
import { authMessages } from "~/core/messages/index.js";
import { resolveApiKey as realResolveApiKey } from "~/domains/auth/index.js";
import type { ApiKeyResult } from "~/domains/auth/types.js";
import type { CommandContext, CommandResult } from "~/shell/commandContext.js";
import { loginWithApiKey as realLoginWithApiKey } from "./loginApiKey.js";
import { loginWithDevice as realLoginWithDevice } from "./loginDevice.js";

export async function handleLogin(ctx: CommandContext): Promise<CommandResult> {
type LoginDeps = {
resolveApiKey?: (
configDir: string,
fs: CommandContext["fs"],
) => Promise<ApiKeyResult | undefined>;
loginWithApiKey?: (ctx: CommandContext) => Promise<CommandResult>;
loginWithDevice?: (ctx: CommandContext) => Promise<CommandResult>;
};

const browserMethod = "browser";

export async function handleLogin(
ctx: CommandContext,
deps: LoginDeps = {},
): Promise<CommandResult> {
if (ctx.ui.mode !== "human") {
ctx.ui.error(authMessages.login.nonInteractive);
return { error: "non-interactive" };
}

const resolveApiKey = deps.resolveApiKey ?? realResolveApiKey;
const existing = await resolveApiKey(ctx.configDir, ctx.fs);
if (existing) {
const reauth = await ctx.ui.confirm(authMessages.login.reAuthPrompt);
Expand All @@ -28,45 +38,42 @@ export async function handleLogin(ctx: CommandContext): Promise<CommandResult> {
ctx.ui.gap();
ctx.ui.intro(authMessages.title);

const result = await ctx.ui.password(
authMessages.promptApiKey,
"Set QAWOLF_API_KEY to authenticate in non-interactive environments.",
);
if (!result.ok) {
// The two credentials do not grant the same access — an API key carries team
// scope a user token does not — so the choice is explicit rather than a
// default that quietly narrows what later commands can do.
const method = await ctx.ui.select(authMessages.login.chooseMethod, [
{
value: browserMethod,
label: authMessages.login.methodBrowser,
hint: authMessages.login.methodBrowserHint,
},
{
value: "api-key",
label: authMessages.login.methodApiKey,
hint: authMessages.login.methodApiKeyHint,
},
]);

if (!method.ok) {
ctx.ui.cancel(authMessages.cancelled);
return;
}

if (!result.value.trim()) {
ctx.ui.cancel(authMessages.cancelled);
return;
if (method.value !== browserMethod) {
return (deps.loginWithApiKey ?? realLoginWithApiKey)(ctx);
}

await ctx.ui.withProgress(
[
{
message: authMessages.verifying,
task: async () => {
const v = await validateApiKey({
platformClient: createPlatformClient(result.value, {
baseUrl: ctx.apiBaseUrl,
fetch: globalThis.fetch,
}),
});
if (!v.valid) throw Error(v.error);
},
},
{
message: authMessages.storing,
task: async () => saveApiKey(ctx.configDir, result.value, ctx.fs),
},
],
([, saveResult]) => {
return saveResult.stored === "file"
? authMessages.storedFile
: authMessages.storedKeychain;
},
);
// Said before the flow starts rather than after it: the browser round trip
// ends in "Signed in as ...", and a caveat printed after that reads as an
// afterthought to a sign-in the person believes already took effect. A
// previous browser session is simply replaced, so only an API key shadows.
if (existing && existing.source !== "browser") {
ctx.ui.warn(
existing.source === "env"
? authMessages.login.apiKeyPrecedence.env
: authMessages.login.apiKeyPrecedence.stored,
);
}

ctx.ui.outro(authMessages.outroSuccess);
return (deps.loginWithDevice ?? realLoginWithDevice)(ctx);
}
54 changes: 54 additions & 0 deletions src/commands/auth/loginApiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { saveApiKey, validateApiKey } from "~/domains/auth/index.js";
import { createPlatformClient } from "~/shell/platform/createPlatformClient.js";
import type { CommandContext, CommandResult } from "~/shell/commandContext.js";
import { authMessages } from "~/core/messages/index.js";

/** Paste-a-key sign-in. Assumes the caller has already shown the intro. */
export async function loginWithApiKey(
ctx: CommandContext,
): Promise<CommandResult> {
const result = await ctx.ui.password(
authMessages.promptApiKey,
"Set QAWOLF_API_KEY to authenticate in non-interactive environments.",
);
if (!result.ok) {
ctx.ui.cancel(authMessages.cancelled);
return;
}

// Normalised once: a pasted key routinely carries whitespace, and validating
// one string while storing another would persist a key that cannot work.
const apiKey = result.value.trim();
if (!apiKey) {
ctx.ui.cancel(authMessages.cancelled);
return;
}

await ctx.ui.withProgress(
[
{
message: authMessages.verifying,
task: async () => {
const v = await validateApiKey({
platformClient: createPlatformClient(apiKey, {
baseUrl: ctx.apiBaseUrl,
fetch: globalThis.fetch,
}),
});
if (!v.valid) throw Error(v.error);
},
},
{
message: authMessages.storing,
task: async () => saveApiKey(ctx.configDir, apiKey, ctx.fs),
},
],
([, saveResult]) => {
return saveResult.stored === "file"
? authMessages.storedFile
: authMessages.storedKeychain;
},
);

ctx.ui.outro(authMessages.outroSuccess);
}
Loading
Loading