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
263 changes: 209 additions & 54 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@
"@superset/trpc": "workspace:*",
"@trpc/client": "^11.7.1",
"date-fns": "^4.1.0",
"ink": "^7.0.1",
"react": "19.2.0",
"superjson": "^2.2.5"
},
"devDependencies": {
"@superset/typescript": "workspace:*",
"@types/react": "~19.2.2",
"bun-types": "^1.3.1",
"typescript": "^5.9.3"
}
Expand Down
109 changes: 109 additions & 0 deletions packages/cli/src/commands/auth/login/LoginUI.tsx
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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{status === "exchanging" ? <Text dimColor>Signing in…</Text> : null}
</Box>
);
}
117 changes: 87 additions & 30 deletions packages/cli/src/commands/auth/login/command.ts
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.",
Expand All @@ -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);
}
Comment thread
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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 = {
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/commands/auth/login/copyToClipboard.ts
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;
}
Loading
Loading