@lanonasis/cli
Version:
Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security
608 lines (607 loc) • 27.7 kB
JavaScript
import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import Table from 'cli-table3';
import { apiClient } from '../utils/api.js';
import { formatDate, truncateText } from '../utils/formatting.js';
// Enhanced VPS-style color scheme
const colors = {
primary: chalk.blue.bold,
success: chalk.green,
warning: chalk.yellow,
error: chalk.red,
info: chalk.cyan,
accent: chalk.magenta,
muted: chalk.gray,
highlight: chalk.white.bold
};
const AUTH_API_KEYS_BASE = '/api/v1/api-keys';
const PROJECTS_API_BASE = '/api/v1/api-keys/projects';
const VALID_ACCESS_LEVELS = ['public', 'authenticated', 'team', 'admin', 'enterprise'];
const VALID_KEY_CONTEXTS = ['personal', 'team', 'enterprise'];
function unwrapApiResponse(response) {
if (response && typeof response === 'object' && 'data' in response) {
return response.data ?? response;
}
return response;
}
function parseScopes(scopes) {
if (!scopes) {
return undefined;
}
const parsed = scopes
.split(',')
.map((scope) => scope.trim())
.filter(Boolean);
return parsed.length > 0 ? parsed : undefined;
}
function parseKeyContext(keyContext) {
if (!keyContext) {
return undefined;
}
const normalized = keyContext.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (VALID_KEY_CONTEXTS.includes(normalized)) {
return normalized;
}
throw new Error('Invalid key context. Allowed: personal, team, enterprise');
}
function exitUnsupported(feature, guidance) {
console.error(colors.error(`✖ ${feature} is not exposed by the current auth-gateway API.`));
for (const line of guidance) {
console.error(colors.muted(` • ${line}`));
}
process.exit(1);
}
const apiKeysCommand = new Command('api-keys')
.alias('keys')
.description(colors.info('🔐 Manage API keys securely with enterprise-grade encryption'));
// ============================================================================
// PROJECT COMMANDS
// ============================================================================
const projectsCommand = new Command('projects')
.description(colors.accent('📁 Manage API key projects and organization'));
projectsCommand
.command('create')
.description('Create a new API key project')
.option('-n, --name <name>', 'Project name')
.option('-d, --description [description]', 'Project description')
.option('-o, --organization-id <id>', 'Organization ID')
.option('--interactive', 'Interactive mode')
.action(async (options) => {
try {
let projectData = {
name: options.name,
description: options.description,
organizationId: options.organizationId
};
if (options.interactive || !projectData.name || !projectData.organizationId) {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project name:',
when: !projectData.name,
validate: (input) => input.length > 0 || 'Project name is required'
},
{
type: 'input',
name: 'description',
message: 'Project description (optional):',
when: !projectData.description
},
{
type: 'input',
name: 'organizationId',
message: 'Organization ID:',
when: !projectData.organizationId,
validate: (input) => {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(input) || 'Please enter a valid UUID';
}
}
]);
projectData = { ...projectData, ...answers };
}
const projectRes = await apiClient.post(PROJECTS_API_BASE, projectData);
const project = unwrapApiResponse(projectRes);
console.log(chalk.green('✅ Project created successfully!'));
console.log(chalk.blue(`Project ID: ${project.id}`));
console.log(chalk.blue(`Name: ${project.name}`));
if (project.description) {
console.log(chalk.blue(`Description: ${project.description}`));
}
}
catch (error) {
console.error(chalk.red('❌ Failed to create project:'), error.message);
process.exit(1);
}
});
projectsCommand
.command('list')
.alias('ls')
.description('List all API key projects')
.option('--json', 'Output as JSON')
.action(async (options) => {
try {
const projects = unwrapApiResponse(await apiClient.get(PROJECTS_API_BASE));
if (options.json) {
console.log(JSON.stringify(projects, null, 2));
return;
}
if (!Array.isArray(projects) || projects.length === 0) {
console.log(chalk.yellow('No projects found'));
return;
}
const table = new Table({
head: ['ID', 'Name', 'Description', 'Owner', 'Created'].map(h => chalk.cyan(h)),
style: { head: [], border: [] }
});
projects.forEach((project) => {
table.push([
truncateText(project.id, 20),
project.name,
truncateText(project.description || '-', 30),
truncateText(project.ownerId, 20),
formatDate(project.createdAt)
]);
});
console.log(table.toString());
console.log(chalk.gray(`Total: ${projects.length} projects`));
}
catch (error) {
console.error(chalk.red('❌ Failed to list projects:'), error.message);
process.exit(1);
}
});
// ============================================================================
// API KEY COMMANDS
// ============================================================================
apiKeysCommand
.command('create')
.description('Create a new API key')
.option('-n, --name <name>', 'API key name')
.option('-d, --description <description>', 'API key description (optional)')
.option('--access-level <level>', 'Access level (public, authenticated, team, admin, enterprise)', 'team')
.option('--key-context <context>', 'Optional memory context (personal, team, enterprise)')
.option('--expires-in-days <days>', 'Expiration in days (default: 365)', '365')
.option('--scopes <scopes>', 'Comma-separated scopes (optional)')
.option('--interactive', 'Interactive mode')
.action(async (options) => {
try {
const accessLevel = (options.accessLevel || 'team').toLowerCase();
const keyContext = parseKeyContext(options.keyContext);
const expiresInDays = parseInt(options.expiresInDays, 10);
if (!VALID_ACCESS_LEVELS.includes(accessLevel)) {
throw new Error('Invalid access level. Allowed: public, authenticated, team, admin, enterprise');
}
if (!Number.isInteger(expiresInDays) || expiresInDays <= 0 || expiresInDays > 3650) {
throw new Error('expires-in-days must be a positive integer up to 3650');
}
let keyData = {
name: options.name,
access_level: accessLevel,
key_context: keyContext,
expires_in_days: expiresInDays,
description: options.description?.trim() || undefined,
scopes: parseScopes(options.scopes)
};
if (options.interactive || !keyData.name) {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'API key name:',
when: !keyData.name,
validate: (input) => input.length > 0 || 'Name is required'
},
{
type: 'input',
name: 'description',
message: 'Description (optional):',
default: keyData.description || ''
},
{
type: 'select',
name: 'access_level',
message: 'Access level:',
choices: VALID_ACCESS_LEVELS,
default: keyData.access_level
},
{
type: 'select',
name: 'key_context',
message: 'Memory context:',
choices: [
{ name: 'legacy / unbounded (default)', value: '' },
...VALID_KEY_CONTEXTS.map((context) => ({ name: context, value: context })),
],
default: keyData.key_context || ''
},
{
type: 'number',
name: 'expires_in_days',
message: 'Expires in days:',
default: keyData.expires_in_days,
validate: (input) => Number.isInteger(input) && input > 0 && input <= 3650 || 'Must be between 1 and 3650 days'
},
{
type: 'input',
name: 'scopes',
message: 'Scopes (comma-separated, optional):',
default: (keyData.scopes || []).join(', ')
}
]);
keyData = {
...keyData,
...answers,
key_context: parseKeyContext(typeof answers.key_context === 'string' ? answers.key_context : undefined) ?? keyData.key_context,
description: typeof answers.description === 'string'
? answers.description.trim() || undefined
: keyData.description,
scopes: parseScopes(typeof answers.scopes === 'string' ? answers.scopes : undefined) ?? keyData.scopes
};
}
const apiKey = unwrapApiResponse(await apiClient.post(AUTH_API_KEYS_BASE, keyData));
console.log(colors.success('🔐 API key created successfully!'));
console.log(colors.info('━'.repeat(50)));
console.log(`${colors.highlight('Key ID:')} ${colors.primary(apiKey.id)}`);
console.log(`${colors.highlight('Name:')} ${colors.accent(apiKey.name)}`);
console.log(`${colors.highlight('Access Level:')} ${colors.info(apiKey.access_level || keyData.access_level)}`);
console.log(`${colors.highlight('Key Context:')} ${colors.info(apiKey.key_context || keyData.key_context || 'legacy')}`);
console.log(`${colors.highlight('Permissions:')} ${colors.muted((apiKey.permissions || keyData.scopes || []).join(', ') || 'legacy:full_access')}`);
if (apiKey.expires_at) {
console.log(`${colors.highlight('Expires At:')} ${colors.warning(formatDate(apiKey.expires_at))}`);
}
if (keyData.description) {
console.log(`${colors.highlight('Description:')} ${colors.muted(keyData.description)}`);
}
console.log(colors.info('━'.repeat(50)));
if (apiKey.key) {
console.log(`${colors.highlight('API Key:')} ${colors.primary(apiKey.key)}`);
console.log(colors.warning('⚠️ Save this key now. It will not be shown again.'));
}
else {
console.log(colors.warning('⚠️ Key value was not returned. If newly created, it cannot be retrieved later.'));
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(colors.error('✖ Failed to create API key:'), colors.muted(errorMessage));
process.exit(1);
}
});
apiKeysCommand
.command('list')
.alias('ls')
.description('List API keys')
.option('--all', 'Include inactive keys')
.option('--json', 'Output as JSON')
.action(async (options) => {
try {
const query = options.all ? '?active_only=false' : '';
const apiKeys = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}${query}`));
if (options.json) {
console.log(JSON.stringify(apiKeys, null, 2));
return;
}
if (apiKeys.length === 0) {
console.log(colors.warning('⚠️ No API keys found'));
console.log(colors.muted('Run: lanonasis api-keys create'));
return;
}
console.log(colors.primary('🔐 API Key Management'));
console.log(colors.info('═'.repeat(80)));
const table = new Table({
head: ['ID', 'Name', 'Access', 'Context', 'Permissions', 'Service', 'Status', 'Expires'].map(h => colors.accent(h)),
style: { head: [], border: [] }
});
apiKeys.forEach((key) => {
const statusColor = key.is_active ? colors.success : colors.error;
table.push([
truncateText(key.id, 20),
key.name,
key.access_level,
key.key_context || 'legacy',
truncateText((key.permissions || []).join(', ') || 'legacy:full_access', 28),
key.service || 'all',
statusColor(key.is_active ? 'active' : 'inactive'),
key.expires_at ? formatDate(key.expires_at) : colors.muted('Never')
]);
});
console.log(table.toString());
console.log(colors.info('═'.repeat(80)));
console.log(colors.muted(`🔢 Total: ${colors.highlight(apiKeys.length)} API keys`));
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(colors.error('✖ Failed to list API keys:'), colors.muted(errorMessage));
process.exit(1);
}
});
apiKeysCommand
.command('get')
.description('Get details of a specific API key')
.argument('<keyId>', 'API key ID')
.option('--json', 'Output as JSON')
.action(async (keyId, options) => {
try {
const apiKey = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/${keyId}`));
if (options.json) {
console.log(JSON.stringify(apiKey, null, 2));
return;
}
console.log(colors.primary('🔍 API Key Details'));
console.log(colors.info('═'.repeat(60)));
console.log(`${colors.highlight('ID:')} ${colors.primary(apiKey.id)}`);
console.log(`${colors.highlight('Name:')} ${colors.accent(apiKey.name)}`);
if (apiKey.description) {
console.log(`${colors.highlight('Description:')} ${colors.muted(apiKey.description)}`);
}
console.log(`${colors.highlight('Access Level:')} ${colors.warning(apiKey.access_level)}`);
console.log(`${colors.highlight('Key Context:')} ${colors.info(apiKey.key_context || 'legacy')}`);
console.log(`${colors.highlight('Permissions:')} ${colors.muted((apiKey.permissions || []).join(', ') || 'legacy:full_access')}`);
console.log(`${colors.highlight('Service Scope:')} ${colors.info(apiKey.service || 'all')}`);
const statusColor = apiKey.is_active ? colors.success : colors.error;
console.log(`${colors.highlight('Status:')} ${statusColor(apiKey.is_active ? 'active' : 'inactive')}`);
if (apiKey.last_used_at) {
console.log(`${colors.highlight('Last Used:')} ${colors.muted(formatDate(apiKey.last_used_at))}`);
}
console.log(`${colors.highlight('Created:')} ${colors.muted(formatDate(apiKey.created_at))}`);
if (apiKey.expires_at) {
console.log(`${colors.highlight('Expires:')} ${colors.warning(formatDate(apiKey.expires_at))}`);
}
console.log(colors.info('═'.repeat(60)));
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(colors.error('✖ Failed to get API key details:'), colors.muted(errorMessage));
process.exit(1);
}
});
apiKeysCommand
.command('update')
.description('Update an API key')
.argument('<keyId>', 'API key ID')
.option('-n, --name <name>', 'New name')
.option('-d, --description <description>', 'New description')
.option('--access-level <level>', 'New access level')
.option('--expires-in-days <days>', 'Set a new expiry in days')
.option('--clear-expiry', 'Remove the current expiry')
.option('--scopes <scopes>', 'Replace scopes with a comma-separated list')
.option('--interactive', 'Interactive mode')
.action(async (keyId, options) => {
try {
let updateData = {};
if (options.name)
updateData.name = options.name;
if (options.description !== undefined)
updateData.description = options.description.trim() || null;
if (options.accessLevel) {
const accessLevel = options.accessLevel.toLowerCase();
if (!VALID_ACCESS_LEVELS.includes(accessLevel)) {
throw new Error('Invalid access level. Allowed: public, authenticated, team, admin, enterprise');
}
updateData.access_level = accessLevel;
}
if (options.expiresInDays) {
const expiresInDays = parseInt(options.expiresInDays, 10);
if (!Number.isInteger(expiresInDays) || expiresInDays <= 0 || expiresInDays > 3650) {
throw new Error('expires-in-days must be a positive integer up to 3650');
}
updateData.expires_in_days = expiresInDays;
}
if (options.clearExpiry)
updateData.clear_expiry = true;
if (options.scopes)
updateData.scopes = parseScopes(options.scopes);
if (options.interactive || Object.keys(updateData).length === 0) {
const current = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/${keyId}`));
const answers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Name:',
default: current.name,
when: !updateData.name
},
{
type: 'input',
name: 'description',
message: 'Description (optional):',
default: current.description || '',
when: !updateData.description
},
{
type: 'select',
name: 'access_level',
message: 'Access level:',
choices: VALID_ACCESS_LEVELS,
default: current.access_level,
when: !updateData.access_level
},
{
type: 'confirm',
name: 'changeExpiry',
message: 'Change expiry?',
default: false,
when: updateData.expires_in_days === undefined && !updateData.clear_expiry
},
{
type: 'number',
name: 'expires_in_days',
message: 'Expires in days:',
default: current.expires_at ? 365 : 365,
when: (answers) => answers.changeExpiry === true && updateData.expires_in_days === undefined && !updateData.clear_expiry,
validate: (input) => Number.isInteger(input) && input > 0 && input <= 3650 || 'Must be between 1 and 3650 days'
},
{
type: 'confirm',
name: 'clear_expiry',
message: 'Clear expiry instead?',
default: false,
when: (answers) => answers.changeExpiry === true && updateData.expires_in_days === undefined && !updateData.clear_expiry && Boolean(current.expires_at)
},
{
type: 'input',
name: 'scopes',
message: 'Scopes (comma-separated, optional):',
default: (current.permissions || []).join(', '),
when: !updateData.scopes
}
]);
updateData = {
...updateData,
...answers,
description: typeof answers.description === 'string'
? answers.description.trim() || null
: updateData.description,
scopes: parseScopes(typeof answers.scopes === 'string' ? answers.scopes : undefined) ?? updateData.scopes
};
delete updateData.changeExpiry;
}
if (Object.keys(updateData).length === 0) {
console.log(colors.warning('🚫 Nothing to update'));
return;
}
const updatedKey = unwrapApiResponse(await apiClient.put(`${AUTH_API_KEYS_BASE}/${keyId}`, updateData));
console.log(colors.success('🔄 API key updated successfully!'));
console.log(colors.info('━'.repeat(40)));
console.log(`${colors.highlight('Name:')} ${colors.accent(updatedKey.name)}`);
if (updatedKey.description || updateData.description) {
console.log(`${colors.highlight('Description:')} ${colors.muted(updatedKey.description || updateData.description)}`);
}
console.log(`${colors.highlight('Access Level:')} ${colors.info(updatedKey.access_level)}`);
console.log(`${colors.highlight('Permissions:')} ${colors.muted((updatedKey.permissions || []).join(', ') || 'legacy:full_access')}`);
console.log(`${colors.highlight('Expires:')} ${updatedKey.expires_at ? colors.warning(formatDate(updatedKey.expires_at)) : colors.muted('Never')}`);
console.log(colors.info('━'.repeat(40)));
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(colors.error('✖ Failed to update API key:'), colors.muted(errorMessage));
process.exit(1);
}
});
apiKeysCommand
.command('delete')
.alias('rm')
.description('Delete an API key')
.argument('<keyId>', 'API key ID')
.option('-f, --force', 'Skip confirmation')
.action(async (keyId, options) => {
try {
if (!options.force) {
const apiKey = unwrapApiResponse(await apiClient.get(`${AUTH_API_KEYS_BASE}/${keyId}`));
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to delete "${apiKey.name}"? This action cannot be undone.`,
default: false
}
]);
if (!confirm) {
console.log(colors.warning('🚫 Operation cancelled'));
return;
}
}
await apiClient.delete(`${AUTH_API_KEYS_BASE}/${keyId}`);
console.log(colors.success('🗑️ API key deleted successfully!'));
}
catch (error) {
console.error(colors.error('✖ Failed to delete API key:'), colors.muted(error.message));
process.exit(1);
}
});
// ============================================================================
// MCP COMMANDS
// ============================================================================
const mcpCommand = new Command('mcp')
.description(colors.accent('🤖 Model Context Protocol (MCP) - Secure AI agent access'));
mcpCommand
.command('register-tool')
.description('Register a new MCP tool')
.option('--tool-id <id>', 'Tool ID')
.option('--tool-name <name>', 'Tool name')
.option('--organization-id <id>', 'Organization ID')
.option('--keys <keys>', 'Comma-separated list of accessible key names')
.option('--environments <envs>', 'Comma-separated list of environments')
.option('--max-sessions <num>', 'Maximum concurrent sessions', '3')
.option('--max-duration <seconds>', 'Maximum session duration in seconds', '900')
.option('--webhook-url <url>', 'Webhook URL for notifications')
.option('--auto-approve', 'Enable auto-approval for low-risk requests')
.option('--risk-level <level>', 'Risk level (low, medium, high, critical)', 'medium')
.option('--interactive', 'Interactive mode')
.action(async (options) => {
void options;
exitUnsupported('MCP tool registration', [
'No /api/v1/auth/api-keys/mcp/* routes exist on the current auth-gateway.',
'Use API key service scoping instead: lanonasis api-keys create or update plus /service-scopes support on the gateway.',
'Manage MCP-specific workflows from the dashboard until dedicated routes are exposed.',
]);
});
mcpCommand
.command('list-tools')
.description('List registered MCP tools')
.option('--json', 'Output as JSON')
.action(async (options) => {
void options;
exitUnsupported('MCP tool listing', [
'No /api/v1/auth/api-keys/mcp/* routes exist on the current auth-gateway.',
'The current gateway exposes configured external services at /api/v1/auth/api-keys/services/configured.',
]);
});
mcpCommand
.command('request-access')
.description('Request access to API keys via MCP')
.option('--tool-id <id>', 'Tool ID')
.option('--organization-id <id>', 'Organization ID')
.option('--keys <keys>', 'Comma-separated list of key names')
.option('--environment <env>', 'Environment (development, staging, production)')
.option('--justification <text>', 'Justification for access')
.option('--duration <seconds>', 'Estimated duration in seconds', '900')
.option('--interactive', 'Interactive mode')
.action(async (options) => {
void options;
exitUnsupported('MCP access requests', [
'No /api/v1/auth/api-keys/mcp/* routes exist on the current auth-gateway.',
'Use platform API keys plus service scoping for current gateway-backed access control.',
]);
});
// ============================================================================
// ANALYTICS COMMANDS
// ============================================================================
const analyticsCommand = new Command('analytics')
.description('View API key usage analytics and security events');
analyticsCommand
.command('usage')
.description('View usage analytics')
.option('--key-id <id>', 'Filter by specific API key')
.option('--days <days>', 'Number of days to look back', '30')
.option('--json', 'Output as JSON')
.action(async (options) => {
void options;
exitUnsupported('API key usage analytics', [
'No /api/v1/auth/api-keys/analytics/* routes exist on the current auth-gateway.',
'Use dashboard reporting or direct platform logs until analytics endpoints are exposed.',
]);
});
analyticsCommand
.command('security-events')
.description('View security events')
.option('--severity <level>', 'Filter by severity (low, medium, high, critical)')
.option('--json', 'Output as JSON')
.action(async (options) => {
void options;
exitUnsupported('API key security event analytics', [
'No /api/v1/auth/api-keys/analytics/* routes exist on the current auth-gateway.',
'Use dashboard security reporting until gateway analytics endpoints are exposed.',
]);
});
// Add subcommands
apiKeysCommand.addCommand(projectsCommand);
apiKeysCommand.addCommand(mcpCommand);
apiKeysCommand.addCommand(analyticsCommand);
export default apiKeysCommand;