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
32 changes: 21 additions & 11 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,24 +534,34 @@ try {
}
}
} else if (topLevelAuthHelp) {
process.stdout.write("Usage: step login\nSign in with the Step account and store a credential.\n");
process.stdout.write(
"Usage: step login [--no-browser]\nSign in with the Step account and store a credential.\n\n --no-browser Print the sign-in URL instead of opening a browser.\n",
);
} else if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
process.stderr.write(
"step login needs an interactive terminal. Set STEP_API_KEY instead, or run it from a terminal.\n",
);
process.exitCode = 1;
} else {
const outcome = await runStepLogin({
authPath: getStepAuthPath(),
themeName: getStepDefaultTheme(),
});
if (outcome.kind === "completed") {
syncStepLoginProfileEndpoint(getStepAuthPath());
process.stdout.write(`Signed in${outcome.profile ? ` with ${outcome.profile.title}` : ""}.\n`);
if (outcome.credentialsPath) process.stdout.write(`Credential written: ${outcome.credentialsPath}\n`);
} else {
process.stderr.write("Sign-in cancelled. No credential was written.\n");
const loginArgs = process.argv.slice(3);
const unknownLoginArg = loginArgs.find((arg) => arg !== "--no-browser");
if (unknownLoginArg) {
process.stderr.write(`Unknown option "${unknownLoginArg}" for "login".\n`);
process.exitCode = 1;
} else {
const outcome = await runStepLogin({
authPath: getStepAuthPath(),
themeName: getStepDefaultTheme(),
noBrowser: loginArgs.includes("--no-browser"),
});
if (outcome.kind === "completed") {
syncStepLoginProfileEndpoint(getStepAuthPath());
process.stdout.write(`Signed in${outcome.profile ? ` with ${outcome.profile.title}` : ""}.\n`);
if (outcome.credentialsPath) process.stdout.write(`Credential written: ${outcome.credentialsPath}\n`);
} else {
process.stderr.write("Sign-in cancelled. No credential was written.\n");
process.exitCode = 1;
}
}
}
} else if (isTopLevelFeedback) {
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/features/step-provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function createStepProviderConfigFromResolved(resolved: ResolvedStepProviderOpti
name: "Step Plan",
isSubscription: true,
login: (callbacks) => loginStepOAuth(callbacks, resolved),
refreshToken: (credentials, signal) => refreshStepOAuth(credentials, resolved, signal),
refreshToken: refreshStepOAuth,
getApiKey: getStepOAuthApiKey,
},
};
Expand Down
20 changes: 18 additions & 2 deletions packages/coding-agent/src/step/login-flow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { ProcessTerminal, type TUI, TuiMainScreen } from "@step-harness/pi-tui";
import { AuthStorage, readStoredCredential } from "../core/auth-storage.ts";
import { loginStepOAuth, STEP_PROVIDER_ID, STEP_STATIC_REFRESH_TOKEN } from "../features/step-provider/index.ts";
import {
defaultStepCliClientInfo,
loginStepOAuth,
STEP_PROVIDER_ID,
STEP_STATIC_REFRESH_TOKEN,
} from "../features/step-provider/index.ts";
import { detectTerminalBackgroundFromEnv, initTheme, resolveThemeSetting, theme } from "../theme/theme.ts";
import { openBrowser } from "../utils/open-browser.ts";
import { resolveStepAgentDir } from "./environment.ts";
Expand All @@ -15,6 +20,7 @@ import {
type StepLoginStep,
} from "./onboarding.ts";
import { StepOnboardingView } from "./onboarding-view.ts";
import { resolveStepCodeVersion } from "./version.ts";

export interface StepLoginHost {
addChild(child: unknown): void;
Expand All @@ -38,6 +44,11 @@ export interface RunStepLoginOptions {
readonly now?: () => Date;
readonly env?: Record<string, string | undefined>;
readonly themeName?: string;
/**
* Never try to launch a browser; only print the URL. Useful over SSH and in
* containers, where a launcher may "succeed" without a window ever appearing.
*/
readonly noBrowser?: boolean;
}

export async function writeStepLoginCredential(input: {
Expand Down Expand Up @@ -224,17 +235,22 @@ export async function runStepLogin(options: RunStepLoginOptions = {}): Promise<S
{
signal: controller.signal,
onAuth: ({ url }) => {
// Show the URL before launching anything: if the launcher hangs or
// the host has no browser, the user still has something to copy.
dispatch({ type: "browserOpened", authUrl: url });
openBrowser(url);
if (!options.noBrowser) openBrowser(url);
},
onDeviceCode: () => {},
// Cloud login never prompts for a callback URL or opens a local port.
onPrompt: async () => "",
onSelect: async () => undefined,
},
{
apiBaseUrl: profile.baseUrl,
authBaseUrl: profile.authBaseUrl,
env: options.env ?? process.env,
loginProfile: choice,
client: defaultStepCliClientInfo(resolveStepCodeVersion(options.env).value),
},
);
dispatch({
Expand Down
10 changes: 9 additions & 1 deletion packages/coding-agent/src/step/onboarding-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Spacer,
Text,
truncateToWidth,
wrapTextWithAnsi,
} from "@step-harness/pi-tui";
import { DynamicBorder } from "../render/dynamic-border.ts";
import { initTheme, theme } from "../theme/theme.ts";
Expand Down Expand Up @@ -126,8 +127,13 @@ export class StepOnboardingView extends Container implements Component, Focusabl
}

override render(width: number): string[] {
const safeWidth = Math.max(20, Math.floor(width));
const safeWidth = Math.max(1, Math.floor(width));
const rows = this.renderRows(safeWidth);
// In particular, never truncate the authorization URL or its query string.
// Remote users need every character, including on a narrow terminal.
if (this.step.kind === "continueInBrowser") {
return rows.flatMap((row) => wrapTextWithAnsi(row, safeWidth));
}
return rows.map((row) => truncateToWidth(row, safeWidth, "", false));
}

Expand Down Expand Up @@ -163,8 +169,10 @@ export class StepOnboardingView extends Container implements Component, Focusabl
rows.push(
"Continue sign-in in your browser:",
"",
// On its own unindented row, wrapping rather than truncating.
theme.fg("accent", this.step.authUrl || "Opening sign-in page..."),
);
rows.push("", muted("If the browser does not open here, copy this URL into a browser on any device."));
rows.push("", muted(" Esc cancel"));
return rows;
case "saving":
Expand Down
14 changes: 8 additions & 6 deletions packages/coding-agent/src/utils/open-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ export function openBrowser(target: string): void {
? ["rundll32", ["url.dll,FileProtocolHandler", target]]
: ["xdg-open", [target]];

// spawn reports launcher failures (for example, missing xdg-open) via an
// error event. Browser launch is best-effort: callers still present the target
// to the user, so keep the launcher failure from becoming a process crash.
spawn(cmd, args, { stdio: "ignore", detached: true })
.on("error", () => {})
.unref();
// Browser launch is best-effort; callers still display the URL.
try {
spawn(cmd, args, { stdio: "ignore", detached: true })
.on("error", () => {})
.unref();
} catch {
// Missing launchers or desktop support must not interrupt cloud login.
}
}
27 changes: 27 additions & 0 deletions packages/coding-agent/test/open-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { openBrowser } from "../src/utils/open-browser.ts";

const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn }));
afterEach(() => spawn.mockReset());

describe("best-effort browser launch", () => {
it("passes the URL without a shell and does not wait for the browser", () => {
const child = Object.assign(new EventEmitter(), { unref: vi.fn() });
spawn.mockReturnValue(child);
const url = "https://example.test/cli-login-remote?flow_id=123&x=y";
expect(openBrowser(url)).toBeUndefined();
expect(spawn.mock.calls[0]?.[1]).toContain(url);
expect(spawn.mock.calls[0]?.[2]).not.toHaveProperty("shell");
expect(child.unref).toHaveBeenCalledOnce();
expect(() => child.emit("error", new Error("ENOENT"))).not.toThrow();
expect(() => child.emit("exit", 3, null)).not.toThrow();
});
it("does not interrupt login on a synchronous launcher failure", () => {
spawn.mockImplementation(() => {
throw new Error("no desktop");
});
expect(() => openBrowser("https://example.test")).not.toThrow();
});
});
Loading
Loading