diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 732dc1305..039d60b59 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,7 +1,8 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { listOrganizations } from "../../lib/api-client.js"; -import { openOrShowUrl } from "../../lib/browser.js"; +import { openBrowser } from "../../lib/browser.js"; +import { setupCopyKeyListener } from "../../lib/clipboard.js"; import { clearAuth, getConfigPath, @@ -9,9 +10,10 @@ import { setAuthToken, } from "../../lib/config.js"; import { AuthError } from "../../lib/errors.js"; -import { success } from "../../lib/formatters/colors.js"; +import { muted, success } from "../../lib/formatters/colors.js"; import { formatDuration } from "../../lib/formatters/human.js"; import { completeOAuthFlow, performDeviceFlow } from "../../lib/oauth.js"; +import { generateQRCode } from "../../lib/qrcode.js"; type LoginFlags = { readonly token?: string; @@ -79,47 +81,73 @@ export const loginCommand = buildCommand({ // Device Flow OAuth stdout.write("Starting authentication...\n\n"); - const tokenResponse = await performDeviceFlow( - { - onUserCode: async ( - userCode, - verificationUri, - verificationUriComplete - ) => { - const browserOpened = await openOrShowUrl( - stdout, + let urlToCopy = ""; + // Object wrapper needed for TypeScript control flow analysis with async callbacks + const keyListener = { cleanup: null as (() => void) | null }; + const stdin = process.stdin; + + try { + const tokenResponse = await performDeviceFlow( + { + onUserCode: async ( + userCode, + verificationUri, verificationUriComplete - ); + ) => { + urlToCopy = verificationUriComplete; + + // Try to open browser (best effort) + const browserOpened = await openBrowser(verificationUriComplete); + + if (browserOpened) { + stdout.write("Opening in browser...\n\n"); + } else { + // Show QR code as fallback when browser can't open + stdout.write("Scan this QR code or visit the URL below:\n\n"); + const qr = await generateQRCode(verificationUriComplete); + stdout.write(qr); + stdout.write("\n"); + } - if (!browserOpened) { - // Show shorter URL + code for easier manual entry stdout.write(`URL: ${verificationUri}\n`); stdout.write(`Code: ${userCode}\n\n`); - } + const copyHint = stdin.isTTY ? ` ${muted("(c to copy)")}` : ""; + stdout.write( + `Browser didn't open? Use the url above to sign in${copyHint}\n\n` + ); + stdout.write("Waiting for authorization...\n"); - stdout.write("Waiting for authorization...\n"); + // Setup keyboard listener for 'c' to copy URL + keyListener.cleanup = setupCopyKeyListener( + stdin, + () => urlToCopy, + stdout + ); + }, + onPolling: () => { + stdout.write("."); + }, }, - onPolling: () => { - // Could add a spinner or dots here - stdout.write("."); - }, - }, - flags.timeout * 1000 - ); + flags.timeout * 1000 + ); - // Clear the polling dots - stdout.write("\n\n"); + // Clear the polling dots + stdout.write("\n\n"); - // Store the token - await completeOAuthFlow(tokenResponse); + // Store the token + await completeOAuthFlow(tokenResponse); - stdout.write(`${success("✓")} Authentication successful!\n`); - stdout.write(` Config saved to: ${getConfigPath()}\n`); + stdout.write(`${success("✓")} Authentication successful!\n`); + stdout.write(` Config saved to: ${getConfigPath()}\n`); - if (tokenResponse.expires_in) { - stdout.write( - ` Token expires in: ${formatDuration(tokenResponse.expires_in)}\n` - ); + if (tokenResponse.expires_in) { + stdout.write( + ` Token expires in: ${formatDuration(tokenResponse.expires_in)}\n` + ); + } + } finally { + // Always cleanup keyboard listener + keyListener.cleanup?.(); } }, }); diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 000000000..ba7e0b414 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,125 @@ +/** + * Clipboard utilities + * + * Cross-platform utilities for copying text to the system clipboard. + * Includes both low-level copy function and interactive keyboard-triggered copy. + */ + +import type { Writer } from "../types/index.js"; +import { success } from "./formatters/colors.js"; + +const CTRL_C = "\x03"; +const CLEAR_LINE = "\r\x1b[K"; + +/** + * Copy text to the system clipboard. + * + * Uses platform-specific commands: + * - macOS: pbcopy + * - Linux: xclip or xsel + * - Windows: clip + * + * @param text - The text to copy to clipboard + * @returns true if copy succeeded, false otherwise + */ +export async function copyToClipboard(text: string): Promise { + const { platform } = process; + + let command: string | null = null; + let args: string[] = []; + + if (platform === "darwin") { + command = Bun.which("pbcopy"); + args = []; + } else if (platform === "win32") { + command = Bun.which("clip"); + args = []; + } else { + // Linux - try xclip first, then xsel + command = Bun.which("xclip"); + if (command) { + args = ["-selection", "clipboard"]; + } else { + command = Bun.which("xsel"); + if (command) { + args = ["--clipboard", "--input"]; + } + } + } + + if (!command) { + return false; + } + + try { + const proc = Bun.spawn([command, ...args], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }); + + proc.stdin.write(text); + proc.stdin.end(); + + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } +} + +/** + * Sets up a keyboard listener that copies text to clipboard when 'c' is pressed. + * Only activates in TTY environments. Returns a cleanup function to restore stdin state. + * + * @param stdin - The stdin stream to listen on + * @param getText - Function that returns the text to copy + * @param stdout - Output stream for feedback messages + * @returns Cleanup function to restore stdin state + */ +export function setupCopyKeyListener( + stdin: NodeJS.ReadStream, + getText: () => string, + stdout: Writer +): () => void { + if (!stdin.isTTY) { + return () => { + /* no-op for non-TTY */ + }; + } + + stdin.setRawMode(true); + stdin.resume(); + + let active = true; + + const onData = async (data: Buffer) => { + const key = data.toString(); + + if (key === "c" || key === "C") { + const text = getText(); + const copied = await copyToClipboard(text); + if (copied && active) { + stdout.write(CLEAR_LINE); + stdout.write(success("Copied!")); + } + } + + if (key === CTRL_C) { + stdin.setRawMode(false); + stdin.pause(); + process.exit(130); + } + }; + + stdin.on("data", onData); + + return () => { + active = false; + stdin.off("data", onData); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + stdin.pause(); + }; +}