feat(cli): add login command and authenticated TUI sessions (#114)
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:
2026-03-15 17:00:08 +00:00
committed by jason.woltje
parent 8aaf229483
commit 997a6d134f
3 changed files with 200 additions and 8 deletions

View File

@@ -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,