mcmail/src/commands/auth.ts
小橘 c12c1913bc feat: initial implementation of mcmail CLI
- Authentication: login / logout / whoami with token caching
- Accounts: list / create / delete / info
- Aliases: list / create / delete
- Domains: list
- TypeScript + ESM + commander.js
- Node fetch (no axios)
- Table and --json output modes
- Auto token refresh on expiry
- MCMAIL_HOST / MCMAIL_USER / MCMAIL_PASSWORD env var support
2026-03-31 17:19:56 +00:00

136 lines
4.1 KiB
TypeScript

import { Command } from 'commander';
import { createInterface } from 'readline';
import { authenticate } from '../api.js';
import { saveCredentials, loadCredentials, clearCredentials } from '../credentials.js';
import { printJson, printTable, formatTimestamp } from '../output.js';
/**
* Read all stdin lines upfront when not a TTY (piped input).
*/
async function readStdinLines(): Promise<string[]> {
if (process.stdin.isTTY) return [];
return new Promise((resolve) => {
const lines: string[] = [];
const rl = createInterface({ input: process.stdin, terminal: false });
rl.on('line', (line) => lines.push(line.trim()));
rl.on('close', () => resolve(lines));
});
}
function promptTTY(question: string, hidden: boolean): Promise<string> {
return new Promise((resolve) => {
if (hidden) {
// Disable echo for password
process.stdout.write(question);
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
let input = '';
const onData = (ch: string) => {
if (ch === '\n' || ch === '\r' || ch === '\u0003') {
process.stdin.setRawMode?.(false);
process.stdin.pause();
process.stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(input);
} else if (ch === '\u007F') {
input = input.slice(0, -1);
} else {
input += ch;
}
};
process.stdin.on('data', onData);
} else {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
}
});
}
export function registerAuthCommands(program: Command): void {
program
.command('login')
.description('Log in to Mailcheap and cache credentials')
.option('--json', 'Output as JSON')
.action(async (opts) => {
try {
let username: string;
let password: string;
if (process.stdin.isTTY) {
username = await promptTTY('Username: ', false);
password = await promptTTY('Password: ', true);
} else {
// Piped/non-interactive: read both lines upfront
const lines = await readStdinLines();
username = lines[0] || '';
password = lines[1] || '';
}
if (!username || !password) {
console.error('Username and password are required.');
process.exit(1);
}
const creds = await authenticate(username, password);
await saveCredentials(creds);
if (opts.json) {
printJson({
username: creds.username,
valid_to: creds.valid_to,
valid_to_human: formatTimestamp(creds.valid_to),
});
} else {
console.log(`✓ Logged in as ${creds.username}`);
console.log(` Token valid until: ${formatTimestamp(creds.valid_to)}`);
}
} catch (err) {
console.error(`✗ Login failed: ${(err as Error).message}`);
process.exit(1);
}
});
program
.command('logout')
.description('Clear cached credentials')
.action(async () => {
await clearCredentials();
console.log('✓ Logged out.');
});
program
.command('whoami')
.description('Show current login status')
.option('--json', 'Output as JSON')
.action(async (opts) => {
const creds = await loadCredentials();
if (!creds) {
console.log('Not logged in.');
process.exit(1);
}
const now = Date.now() / 1000;
const expired = now > creds.valid_to - 60;
if (opts.json) {
printJson({
username: creds.username,
valid_to: creds.valid_to,
valid_to_human: formatTimestamp(creds.valid_to),
expired,
});
} else {
printTable([
{ Field: 'Username', Value: creds.username },
{ Field: 'Token valid until', Value: formatTimestamp(creds.valid_to) },
{ Field: 'Status', Value: expired ? '⚠ Expired' : '✓ Active' },
]);
}
});
}