diff --git a/packages/cli/src/auth.ts b/packages/cli/src/auth.ts new file mode 100644 index 0000000..5279502 --- /dev/null +++ b/packages/cli/src/auth.ts @@ -0,0 +1,115 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { homedir } from 'node:os'; + +const SESSION_DIR = resolve(homedir(), '.mosaic'); +const SESSION_FILE = resolve(SESSION_DIR, 'session.json'); + +interface StoredSession { + gatewayUrl: string; + cookie: string; + userId: string; + email: string; + expiresAt: string; +} + +export interface AuthResult { + cookie: string; + userId: string; + email: string; +} + +/** + * Sign in to the gateway and return the session cookie. + */ +export async function signIn( + gatewayUrl: string, + email: string, + password: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + redirect: 'manual', + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Sign-in failed (${res.status}): ${body}`); + } + + // Extract set-cookie header + const setCookieHeader = res.headers.getSetCookie?.() ?? []; + const sessionCookie = setCookieHeader + .map((c) => c.split(';')[0]!) + .filter((c) => c.startsWith('better-auth.session_token=')) + .join('; '); + + if (!sessionCookie) { + throw new Error('No session cookie returned from sign-in'); + } + + // Parse the response body for user info + const data = (await res.json()) as { user?: { id: string; email: string } }; + const userId = data.user?.id ?? 'unknown'; + const userEmail = data.user?.email ?? email; + + return { cookie: sessionCookie, userId, email: userEmail }; +} + +/** + * Save session to ~/.mosaic/session.json + */ +export function saveSession(gatewayUrl: string, auth: AuthResult): void { + if (!existsSync(SESSION_DIR)) { + mkdirSync(SESSION_DIR, { recursive: true }); + } + + const session: StoredSession = { + gatewayUrl, + cookie: auth.cookie, + userId: auth.userId, + email: auth.email, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days + }; + + writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8'); +} + +/** + * Load a saved session. Returns null if no session, expired, or wrong gateway. + */ +export function loadSession(gatewayUrl: string): AuthResult | null { + if (!existsSync(SESSION_FILE)) return null; + + try { + const raw = readFileSync(SESSION_FILE, 'utf-8'); + const session = JSON.parse(raw) as StoredSession; + + if (session.gatewayUrl !== gatewayUrl) return null; + if (new Date(session.expiresAt) < new Date()) return null; + + return { + cookie: session.cookie, + userId: session.userId, + email: session.email, + }; + } catch { + return null; + } +} + +/** + * Validate that a stored session is still active by hitting get-session. + */ +export async function validateSession(gatewayUrl: string, cookie: string): Promise { + try { + const res = await fetch(`${gatewayUrl}/api/auth/get-session`, { + headers: { Cookie: cookie }, + }); + return res.ok; + } catch { + return false; + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2833cf8..4e5dea2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,12 +8,83 @@ const program = new Command(); program.name('mosaic').description('Mosaic Stack CLI').version('0.0.0'); +// ─── login ────────────────────────────────────────────────────────────── + +program + .command('login') + .description('Sign in to a Mosaic gateway') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .option('-e, --email ', 'Email address') + .option('-p, --password ', 'Password') + .action(async (opts: { gateway: string; email?: string; password?: string }) => { + const { signIn, saveSession } = await import('./auth.js'); + + let email = opts.email; + let password = opts.password; + + if (!email || !password) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + if (!email) email = await ask('Email: '); + if (!password) password = await ask('Password: '); + rl.close(); + } + + try { + const auth = await signIn(opts.gateway, email, password); + saveSession(opts.gateway, auth); + console.log(`Signed in as ${auth.email} (${opts.gateway})`); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + +// ─── tui ──────────────────────────────────────────────────────────────── + program .command('tui') .description('Launch interactive TUI connected to the gateway') .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') .option('-c, --conversation ', 'Resume a conversation by ID') .action(async (opts: { gateway: string; conversation?: string }) => { + const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js'); + + // Try loading saved session + let session = loadSession(opts.gateway); + + if (session) { + const valid = await validateSession(opts.gateway, session.cookie); + if (!valid) { + console.log('Session expired. Please sign in again.'); + session = null; + } + } + + // No valid session — prompt for credentials + if (!session) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + + console.log(`Sign in to ${opts.gateway}`); + const email = await ask('Email: '); + const password = await ask('Password: '); + rl.close(); + + try { + const auth = await signIn(opts.gateway, email, password); + saveSession(opts.gateway, auth); + session = auth; + console.log(`Signed in as ${auth.email}\n`); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + } + // Dynamic import to avoid loading React/Ink for other commands const { render } = await import('ink'); const React = await import('react'); @@ -23,28 +94,29 @@ program React.createElement(TuiApp, { gatewayUrl: opts.gateway, conversationId: opts.conversation, + sessionCookie: session.cookie, }), ); }); -// prdy subcommand -// buildPrdyCli() returns a wrapper Command; extract the 'prdy' subcommand from it. -// Type cast is required because @mosaic/prdy uses commander@12 while @mosaic/cli uses commander@13. +// ─── prdy ─────────────────────────────────────────────────────────────── + const prdyWrapper = buildPrdyCli(); const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy'); if (prdyCmd !== undefined) { program.addCommand(prdyCmd as unknown as Command); } -// quality-rails subcommand -// createQualityRailsCli() returns a wrapper Command; extract the 'quality-rails' subcommand. +// ─── quality-rails ────────────────────────────────────────────────────── + const qrWrapper = createQualityRailsCli(); const qrCmd = qrWrapper.commands.find((c) => c.name() === 'quality-rails'); if (qrCmd !== undefined) { program.addCommand(qrCmd as unknown as Command); } -// wizard subcommand — wraps @mosaic/mosaic installation wizard +// ─── wizard ───────────────────────────────────────────────────────────── + program .command('wizard') .description('Run the Mosaic installation wizard') @@ -60,7 +132,6 @@ program .option('--pronouns ', 'Your pronouns') .option('--timezone ', 'Your timezone') .action(async (opts: Record) => { - // Dynamic import to avoid loading wizard deps for other commands const { runWizard, ClackPrompter, diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 831d4a8..3a6698f 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -12,9 +12,14 @@ interface Message { interface TuiAppProps { gatewayUrl: string; conversationId?: string; + sessionCookie?: string; } -export function TuiApp({ gatewayUrl, conversationId: initialConversationId }: TuiAppProps) { +export function TuiApp({ + gatewayUrl, + conversationId: initialConversationId, + sessionCookie, +}: TuiAppProps) { const { exit } = useApp(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); @@ -27,6 +32,7 @@ export function TuiApp({ gatewayUrl, conversationId: initialConversationId }: Tu useEffect(() => { const socket = io(`${gatewayUrl}/chat`, { transports: ['websocket'], + extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined, }); socketRef.current = socket;