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
94 changes: 61 additions & 33 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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,
isAuthenticated,
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;
Expand Down Expand Up @@ -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?.();
}
},
});
125 changes: 125 additions & 0 deletions src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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();
};
}
Loading