-
Notifications
You must be signed in to change notification settings - Fork 972
feat(cli): always-on paste fallback for auth login (Ink UI) #4072
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { Box, Text, useInput, usePaste } from "ink"; | ||
| import { useEffect, useState } from "react"; | ||
|
|
||
| export type LoginStatus = "starting" | "waiting" | "exchanging" | "done"; | ||
|
|
||
| export interface LoginUIProps { | ||
| url: string | null; | ||
| status: LoginStatus; | ||
| onSubmit: (code: string) => void; | ||
| onCancel: () => void; | ||
| onCopy: () => Promise<boolean>; | ||
| } | ||
|
|
||
| export function LoginUI({ | ||
| url, | ||
| status, | ||
| onSubmit, | ||
| onCancel, | ||
| onCopy, | ||
| }: LoginUIProps) { | ||
| const [value, setValue] = useState(""); | ||
| const [validationError, setValidationError] = useState<string | null>(null); | ||
| const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); | ||
|
|
||
| useEffect(() => { | ||
| if (copyState !== "copied") return; | ||
| const id = setTimeout(() => setCopyState("idle"), 1500); | ||
| return () => clearTimeout(id); | ||
| }, [copyState]); | ||
|
|
||
| useInput((input, key) => { | ||
| if (status === "exchanging" || status === "done") return; | ||
|
|
||
| if (key.escape || (key.ctrl && input === "c")) { | ||
| onCancel(); | ||
| return; | ||
| } | ||
|
|
||
| if (key.return) { | ||
| const submitted = value.trim(); | ||
| if (!submitted.includes("#")) { | ||
| setValidationError("Paste the entire value"); | ||
| return; | ||
| } | ||
| setValidationError(null); | ||
| onSubmit(submitted); | ||
| return; | ||
| } | ||
|
|
||
| if (key.backspace || key.delete) { | ||
| setValue((v) => v.slice(0, -1)); | ||
| setValidationError(null); | ||
| return; | ||
| } | ||
|
|
||
| // `c` keybinding for copy — fires only when the buffer is empty so | ||
| // pasted/typed codes aren't hijacked. usePaste handles multi-char | ||
| // paste content separately, so this only races single-key 'c' typing. | ||
| if (input === "c" && !key.ctrl && !key.meta && value.length === 0 && url) { | ||
| void onCopy().then((ok) => { | ||
| if (ok) setCopyState("copied"); | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (input && !key.ctrl && !key.meta) { | ||
| setValue((v) => v + input); | ||
| setValidationError(null); | ||
| } | ||
| }); | ||
|
|
||
| usePaste((text) => { | ||
| setValue((v) => v + text.replace(/[\r\n]+/g, "")); | ||
| setValidationError(null); | ||
| }); | ||
|
|
||
| const showCursor = status === "waiting"; | ||
|
|
||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Text bold>superset auth login</Text> | ||
| <Text> </Text> | ||
| <Box flexDirection="row"> | ||
| <Text>Browser didn't open? Use the url below to sign in </Text> | ||
| <Text dimColor>(press c to copy)</Text> | ||
| </Box> | ||
| <Text> </Text> | ||
| <Text color="cyan">{url ?? "Generating sign-in link…"}</Text> | ||
| <Text> </Text> | ||
| <Box flexDirection="row"> | ||
| <Text>Paste code here if prompted </Text> | ||
| <Text color="cyan">{">"}</Text> | ||
| <Text> {value}</Text> | ||
| {showCursor && <Text inverse> </Text>} | ||
| </Box> | ||
| {validationError ? <Text color="red">{validationError}</Text> : null} | ||
| {copyState === "copied" ? ( | ||
| <Text color="green">✓ URL copied to clipboard</Text> | ||
| ) : ( | ||
| <Text> </Text> | ||
| )} | ||
| <Text> </Text> | ||
| <Text dimColor italic> | ||
| Esc / Ctrl+C to cancel | ||
| </Text> | ||
| {status === "exchanging" ? <Text dimColor>Signing in…</Text> : null} | ||
| </Box> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,13 @@ | ||
| import * as p from "@clack/prompts"; | ||
| import { CLIError, string } from "@superset/cli-framework"; | ||
| import { render } from "ink"; | ||
| import { createElement } from "react"; | ||
| import { createApiClient } from "../../../lib/api-client"; | ||
| import { login } from "../../../lib/auth"; | ||
| import { command } from "../../../lib/command"; | ||
| import { readConfig, writeConfig } from "../../../lib/config"; | ||
| import { copyToClipboard } from "./copyToClipboard"; | ||
| import { LoginUI, type LoginUIProps } from "./LoginUI"; | ||
|
|
||
| export default command({ | ||
| description: "Authenticate with Superset. Re-run to switch organizations.", | ||
|
|
@@ -15,42 +19,95 @@ export default command({ | |
| }, | ||
| run: async (opts) => { | ||
| const config = readConfig(); | ||
| const useInk = | ||
| process.stdout.isTTY && process.stdin.isTTY && !process.env.CI; | ||
|
|
||
| let pasteResolve: ((code: string) => void) | null = null; | ||
| let pasteReject: ((err: Error) => void) | null = null; | ||
| const pastePromise = new Promise<string>((resolve, reject) => { | ||
| pasteResolve = resolve; | ||
| pasteReject = reject; | ||
| }); | ||
|
|
||
| let currentProps: LoginUIProps = { | ||
| url: null, | ||
| status: "starting", | ||
| onSubmit: (code) => pasteResolve?.(code), | ||
| onCancel: () => pasteReject?.(new CLIError("Login cancelled")), | ||
| onCopy: async () => false, | ||
| }; | ||
|
|
||
| const inkInstance = useInk | ||
| ? render(createElement(LoginUI, currentProps), { exitOnCtrlC: false }) | ||
| : null; | ||
|
|
||
| p.intro("superset auth login"); | ||
| const update = (patch: Partial<LoginUIProps>) => { | ||
| currentProps = { ...currentProps, ...patch }; | ||
| inkInstance?.rerender(createElement(LoginUI, currentProps)); | ||
| }; | ||
|
|
||
| const spinner = process.stdout.isTTY ? p.spinner() : null; | ||
| let spinnerActive = false; | ||
| if (!inkInstance) { | ||
| p.intro("superset auth login"); | ||
| } | ||
|
|
||
| const result = await login(opts.signal, { | ||
| onAuthorizationUrl: (url, mode) => { | ||
| p.log.step("Opening browser to authorize…"); | ||
| p.log.message("If the browser didn't open, visit:"); | ||
| p.log.message(url); | ||
| if (mode === "loopback") { | ||
| if (spinner) { | ||
| spinner.start("Waiting for browser callback…"); | ||
| spinnerActive = true; | ||
| let result: Awaited<ReturnType<typeof login>> | null = null; | ||
| let cancelled = false; | ||
| try { | ||
| result = await login(opts.signal, { | ||
| onAuthorizationUrl: (url) => { | ||
| if (inkInstance) { | ||
| update({ | ||
| url, | ||
| status: "waiting", | ||
| onCopy: () => copyToClipboard(url), | ||
| }); | ||
| } else { | ||
| p.log.info("Waiting for browser callback…"); | ||
| p.log.message("Browser didn't open? Use the url below to sign in"); | ||
| p.log.message(url); | ||
| } | ||
| } | ||
| }, | ||
| promptForPastedCode: async () => { | ||
| const pasted = await p.text({ | ||
| message: "Paste the code from the browser", | ||
| validate: (value) => | ||
| value.includes("#") ? undefined : "Paste the entire value", | ||
| }); | ||
| if (p.isCancel(pasted)) { | ||
| throw new CLIError("Login cancelled"); | ||
| } | ||
| return pasted; | ||
| }, | ||
| }); | ||
| }, | ||
| promptForPastedCode: async (signal) => { | ||
| if (!inkInstance) { | ||
| const pasted = await p.text({ | ||
| message: "Paste code here if prompted", | ||
| validate: (value) => | ||
| value.includes("#") ? undefined : "Paste the entire value", | ||
| }); | ||
| if (signal.aborted) return ""; | ||
| if (p.isCancel(pasted)) { | ||
| throw new CLIError("Login cancelled"); | ||
| } | ||
| return pasted; | ||
| } | ||
| const onAbort = () => pasteResolve?.(""); | ||
| signal.addEventListener("abort", onAbort); | ||
| try { | ||
| const code = await pastePromise; | ||
| if (signal.aborted) return ""; | ||
| update({ status: "exchanging" }); | ||
| return code; | ||
| } finally { | ||
| signal.removeEventListener("abort", onAbort); | ||
| } | ||
|
saddlepaddle marked this conversation as resolved.
|
||
| }, | ||
| }); | ||
| if (inkInstance) update({ status: "done" }); | ||
| } catch (err) { | ||
| if (err instanceof CLIError && err.message === "Login cancelled") { | ||
| cancelled = true; | ||
| } else { | ||
| throw err; | ||
| } | ||
|
Comment on lines
+95
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The check Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/cli/src/commands/auth/login/command.ts
Line: 94-99
Comment:
**Fragile error identification by message string**
The check `err.message === "Login cancelled"` is a stringly-typed sentinel. If the message is ever changed (e.g. for i18n or copy consistency), the catch block will silently re-throw as an unhandled error instead of producing the clean `Login interrupted` exit. Consider tagging `CLIError` with a machine-readable code or adding a custom subclass (e.g. `LoginCancelledError`) so the check is refactoring-safe.
How can I resolve this? If you propose a fix, please make it concise. |
||
| } finally { | ||
| if (inkInstance) { | ||
| inkInstance.unmount(); | ||
| await inkInstance.waitUntilExit().catch(() => {}); | ||
| } | ||
| } | ||
|
|
||
| if (spinnerActive) { | ||
| spinner?.stop(); | ||
| spinnerActive = false; | ||
| if (cancelled || !result) { | ||
| p.cancel("Login interrupted"); | ||
| return { data: { loggedIn: false } }; | ||
| } | ||
|
|
||
| config.auth = { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { spawn } from "node:child_process"; | ||
|
|
||
| type Candidate = { command: string; args?: string[] }; | ||
|
|
||
| function nativeCandidates(): Candidate[] { | ||
| switch (process.platform) { | ||
| case "darwin": | ||
| return [{ command: "pbcopy" }]; | ||
| case "win32": | ||
| return [{ command: "clip" }]; | ||
| default: | ||
| return [ | ||
| { command: "wl-copy" }, | ||
| { command: "xclip", args: ["-selection", "clipboard"] }, | ||
| { command: "xsel", args: ["--clipboard", "--input"] }, | ||
| ]; | ||
| } | ||
| } | ||
|
|
||
| function tryCandidate(text: string, c: Candidate): Promise<boolean> { | ||
| return new Promise((resolve) => { | ||
| let child: ReturnType<typeof spawn>; | ||
| try { | ||
| child = spawn(c.command, c.args ?? [], { | ||
| stdio: ["pipe", "ignore", "ignore"], | ||
| }); | ||
| } catch { | ||
| resolve(false); | ||
| return; | ||
| } | ||
| child.on("error", () => resolve(false)); | ||
| child.on("close", (code) => resolve(code === 0)); | ||
| child.stdin?.on("error", () => resolve(false)); | ||
| child.stdin?.end(text); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * OSC 52 — `ESC ] 52 ; c ; <base64> BEL`. The terminal emulator (running on | ||
| * the user's local machine, even when this CLI is on a remote host over SSH) | ||
| * intercepts the sequence and writes the payload to the system clipboard. | ||
| * Terminals that don't support it silently drop the sequence. | ||
| */ | ||
| function emitOsc52(text: string): boolean { | ||
| if (!process.stdout.isTTY) return false; | ||
| const payload = Buffer.from(text, "utf8").toString("base64"); | ||
| process.stdout.write(`\x1b]52;c;${payload}\x07`); | ||
| return true; | ||
| } | ||
|
|
||
| export async function copyToClipboard(text: string): Promise<boolean> { | ||
| // OSC 52 first — works across SSH and is the only path that reaches the | ||
| // user's clipboard on a remote host. Native binaries run as a fallback | ||
| // for local users whose terminal blocks OSC 52 (some iTerm2 configs, | ||
| // tmux without `set-clipboard on`, etc.). | ||
| const osc = emitOsc52(text); | ||
| let native = false; | ||
| for (const c of nativeCandidates()) { | ||
| if (await tryCandidate(text, c)) { | ||
| native = true; | ||
| break; | ||
| } | ||
| } | ||
| return osc || native; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.