184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
/**
|
|
* Ink UI components for OAuth setup flow
|
|
*/
|
|
|
|
import { hostname } from "node:os";
|
|
import { Box, Text, useApp, useInput } from "ink";
|
|
import { useState } from "react";
|
|
import { asciiLogo } from "../cli/components/AsciiArt.ts";
|
|
import { colors } from "../cli/components/colors";
|
|
import { settingsManager } from "../settings-manager";
|
|
import { pollForToken, requestDeviceCode } from "./oauth";
|
|
|
|
type SetupMode = "menu" | "device-code" | "auth-code" | "self-host" | "done";
|
|
|
|
interface SetupUIProps {
|
|
onComplete: () => void;
|
|
}
|
|
|
|
export function SetupUI({ onComplete }: SetupUIProps) {
|
|
const [mode, setMode] = useState<SetupMode>("menu");
|
|
const [selectedOption, setSelectedOption] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [_deviceCode, setDeviceCode] = useState<string | null>(null);
|
|
const [userCode, setUserCode] = useState<string | null>(null);
|
|
const [verificationUri, setVerificationUri] = useState<string | null>(null);
|
|
|
|
const { exit } = useApp();
|
|
|
|
// Handle menu navigation
|
|
useInput(
|
|
(_input, key) => {
|
|
if (mode === "menu") {
|
|
if (key.upArrow) {
|
|
setSelectedOption((prev) => Math.max(0, prev - 1));
|
|
} else if (key.downArrow) {
|
|
setSelectedOption((prev) => Math.min(2, prev + 1));
|
|
} else if (key.return) {
|
|
if (selectedOption === 0) {
|
|
// Login to Letta Cloud - start device code flow
|
|
setMode("device-code");
|
|
startDeviceCodeFlow();
|
|
} else if (selectedOption === 1) {
|
|
exit();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ isActive: mode === "menu" },
|
|
);
|
|
|
|
const startDeviceCodeFlow = async () => {
|
|
try {
|
|
const deviceData = await requestDeviceCode();
|
|
setDeviceCode(deviceData.device_code);
|
|
setUserCode(deviceData.user_code);
|
|
setVerificationUri(deviceData.verification_uri_complete);
|
|
|
|
// Auto-open browser (fire-and-forget, never crash)
|
|
// Uses promise chaining to ensure error handler is attached immediately
|
|
// after promise resolution, preventing race conditions with error events
|
|
import("open")
|
|
.then(({ default: open }) =>
|
|
open(deviceData.verification_uri_complete, { wait: false }),
|
|
)
|
|
.then((subprocess) => {
|
|
subprocess.on("error", () => {
|
|
// Silently ignore - user can manually visit the URL shown above
|
|
});
|
|
})
|
|
.catch(() => {
|
|
// Silently ignore any failures (WSL PowerShell issues, missing xdg-open, etc.)
|
|
});
|
|
|
|
// Get or generate device ID
|
|
const deviceId = settingsManager.getOrCreateDeviceId();
|
|
const deviceName = hostname();
|
|
|
|
// Start polling in background
|
|
pollForToken(
|
|
deviceData.device_code,
|
|
deviceData.interval,
|
|
deviceData.expires_in,
|
|
deviceId,
|
|
deviceName,
|
|
)
|
|
.then(async (tokens) => {
|
|
// Save tokens using secrets for secure storage
|
|
// Note: LETTA_BASE_URL is intentionally NOT saved to settings
|
|
// It should only come from environment variables
|
|
const now = Date.now();
|
|
|
|
try {
|
|
// Update settings with non-sensitive data and tokens (secrets handles secure storage)
|
|
settingsManager.updateSettings({
|
|
env: {
|
|
...settingsManager.getSettings().env,
|
|
LETTA_API_KEY: tokens.access_token,
|
|
},
|
|
refreshToken: tokens.refresh_token,
|
|
tokenExpiresAt: now + tokens.expires_in * 1000,
|
|
});
|
|
|
|
// Wait for all pending writes (keychain, disk) to complete before continuing
|
|
// This prevents a race condition where main() validation runs before tokens are persisted
|
|
await settingsManager.flush();
|
|
|
|
setMode("done");
|
|
setTimeout(() => onComplete(), 1000);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
setError(err.message);
|
|
});
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
|
|
if (mode === "done") {
|
|
return (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Text color="green">✓ Setup complete!</Text>
|
|
<Text dimColor>Starting Letta Code...</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Text color="red">✗ Error: {error}</Text>
|
|
<Text dimColor>Press Ctrl+C to exit</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (mode === "device-code") {
|
|
return (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Text color={colors.welcome.accent}>{asciiLogo}</Text>
|
|
<Text bold>Login to the Letta Developer Platform</Text>
|
|
<Text> </Text>
|
|
<Text dimColor>Opening browser for authorization...</Text>
|
|
<Text> </Text>
|
|
<Text>
|
|
Your authorization code:{" "}
|
|
<Text color="yellow" bold>
|
|
{userCode}
|
|
</Text>
|
|
</Text>
|
|
<Text dimColor>If browser didn't open, visit: {verificationUri}</Text>
|
|
<Text> </Text>
|
|
<Text dimColor>Waiting for you to authorize in the browser...</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Main menu
|
|
return (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Text color={colors.welcome.accent}>{asciiLogo}</Text>
|
|
<Text bold>Welcome to Letta Code!</Text>
|
|
<Text> </Text>
|
|
<Text>Let's get you authenticated:</Text>
|
|
<Text> </Text>
|
|
<Box>
|
|
<Text color={selectedOption === 0 ? "cyan" : undefined}>
|
|
{selectedOption === 0 ? "→" : " "} Login to the Letta Developer
|
|
Platform
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text color={selectedOption === 1 ? "cyan" : undefined}>
|
|
{selectedOption === 1 ? "→" : " "} Exit
|
|
</Text>
|
|
</Box>
|
|
<Text> </Text>
|
|
<Text dimColor>Use ↑/↓ to navigate, Enter to select</Text>
|
|
</Box>
|
|
);
|
|
}
|