feat(cli): add login command and authenticated TUI sessions
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- New `mosaic login` command: signs in via email/password, stores session cookie at ~/.mosaic/session.json (7-day expiry) - `mosaic tui` now authenticates before connecting WebSocket: loads saved session, validates it, prompts for credentials if needed - TUI passes session cookie via socket.io extraHeaders so the ChatGateway accepts the WebSocket connection - Session is reused across invocations until it expires Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
packages/cli/src/auth.ts
Normal file
115
packages/cli/src/auth.ts
Normal file
@@ -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<AuthResult> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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 <url>', 'Gateway URL', 'http://localhost:4000')
|
||||
.option('-e, --email <email>', 'Email address')
|
||||
.option('-p, --password <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<string> => 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 <url>', 'Gateway URL', 'http://localhost:4000')
|
||||
.option('-c, --conversation <id>', '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<string> => 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 <pronouns>', 'Your pronouns')
|
||||
.option('--timezone <tz>', 'Your timezone')
|
||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||||
// Dynamic import to avoid loading wizard deps for other commands
|
||||
const {
|
||||
runWizard,
|
||||
ClackPrompter,
|
||||
|
||||
@@ -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<Message[]>([]);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user