feat(cli): add login command and authenticated TUI sessions (#114)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #114.
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');
|
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
|
program
|
||||||
.command('tui')
|
.command('tui')
|
||||||
.description('Launch interactive TUI connected to the gateway')
|
.description('Launch interactive TUI connected to the gateway')
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||||
.action(async (opts: { gateway: string; conversation?: string }) => {
|
.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
|
// Dynamic import to avoid loading React/Ink for other commands
|
||||||
const { render } = await import('ink');
|
const { render } = await import('ink');
|
||||||
const React = await import('react');
|
const React = await import('react');
|
||||||
@@ -23,28 +94,29 @@ program
|
|||||||
React.createElement(TuiApp, {
|
React.createElement(TuiApp, {
|
||||||
gatewayUrl: opts.gateway,
|
gatewayUrl: opts.gateway,
|
||||||
conversationId: opts.conversation,
|
conversationId: opts.conversation,
|
||||||
|
sessionCookie: session.cookie,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// prdy subcommand
|
// ─── prdy ───────────────────────────────────────────────────────────────
|
||||||
// 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.
|
|
||||||
const prdyWrapper = buildPrdyCli();
|
const prdyWrapper = buildPrdyCli();
|
||||||
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
|
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
|
||||||
if (prdyCmd !== undefined) {
|
if (prdyCmd !== undefined) {
|
||||||
program.addCommand(prdyCmd as unknown as Command);
|
program.addCommand(prdyCmd as unknown as Command);
|
||||||
}
|
}
|
||||||
|
|
||||||
// quality-rails subcommand
|
// ─── quality-rails ──────────────────────────────────────────────────────
|
||||||
// createQualityRailsCli() returns a wrapper Command; extract the 'quality-rails' subcommand.
|
|
||||||
const qrWrapper = createQualityRailsCli();
|
const qrWrapper = createQualityRailsCli();
|
||||||
const qrCmd = qrWrapper.commands.find((c) => c.name() === 'quality-rails');
|
const qrCmd = qrWrapper.commands.find((c) => c.name() === 'quality-rails');
|
||||||
if (qrCmd !== undefined) {
|
if (qrCmd !== undefined) {
|
||||||
program.addCommand(qrCmd as unknown as Command);
|
program.addCommand(qrCmd as unknown as Command);
|
||||||
}
|
}
|
||||||
|
|
||||||
// wizard subcommand — wraps @mosaic/mosaic installation wizard
|
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('wizard')
|
.command('wizard')
|
||||||
.description('Run the Mosaic installation wizard')
|
.description('Run the Mosaic installation wizard')
|
||||||
@@ -60,7 +132,6 @@ program
|
|||||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
.option('--pronouns <pronouns>', 'Your pronouns')
|
||||||
.option('--timezone <tz>', 'Your timezone')
|
.option('--timezone <tz>', 'Your timezone')
|
||||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||||||
// Dynamic import to avoid loading wizard deps for other commands
|
|
||||||
const {
|
const {
|
||||||
runWizard,
|
runWizard,
|
||||||
ClackPrompter,
|
ClackPrompter,
|
||||||
|
|||||||
@@ -12,9 +12,14 @@ interface Message {
|
|||||||
interface TuiAppProps {
|
interface TuiAppProps {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
|
sessionCookie?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({ gatewayUrl, conversationId: initialConversationId }: TuiAppProps) {
|
export function TuiApp({
|
||||||
|
gatewayUrl,
|
||||||
|
conversationId: initialConversationId,
|
||||||
|
sessionCookie,
|
||||||
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -27,6 +32,7 @@ export function TuiApp({ gatewayUrl, conversationId: initialConversationId }: Tu
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = io(`${gatewayUrl}/chat`, {
|
const socket = io(`${gatewayUrl}/chat`, {
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
|
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|||||||
Reference in New Issue
Block a user