From 1e6554af3e77eefe8f2c9fa0f8b910b638854b68 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 23 Jan 2026 19:01:48 +0100 Subject: [PATCH] feat(auth): add press 'c' to copy URL during login flow Add clipboard utility and keyboard listener to allow users to quickly copy the authentication URL by pressing 'c' during the OAuth device flow. This helps when the browser doesn't open automatically. --- src/commands/auth/login.ts | 94 ++++++++++++++++++---------- src/lib/clipboard.ts | 125 +++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 src/lib/clipboard.ts 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(); + }; +}