- Replace admin role with granular roles: view, create, delete, edit, run - Two binding types: resource bindings (role+resource+optional name) and operation bindings (role:run + action like backup, logs, impersonate) - Name-scoped resource bindings for per-instance access control - Remove role from project members (all permissions via RBAC) - Add users, groups, RBAC CRUD endpoints and CLI commands - describe user/group shows all RBAC access (direct + inherited) - create rbac supports --subject, --binding, --operation flags - Backup/restore handles users, groups, RBAC definitions - mcplocal project-based MCP endpoint discovery - Full test coverage for all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
199 lines
6.8 KiB
TypeScript
199 lines
6.8 KiB
TypeScript
import { Command } from 'commander';
|
|
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
import { resolve, join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
|
|
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
|
|
import { formatJson, formatYaml } from '../formatters/index.js';
|
|
import { saveCredentials, loadCredentials } from '../auth/index.js';
|
|
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
|
|
import type { ApiClient } from '../api-client.js';
|
|
|
|
interface McpConfig {
|
|
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
|
|
}
|
|
|
|
export interface ConfigCommandDeps {
|
|
configDeps: Partial<ConfigLoaderDeps>;
|
|
log: (...args: string[]) => void;
|
|
}
|
|
|
|
export interface ConfigApiDeps {
|
|
client: ApiClient;
|
|
credentialsDeps: Partial<CredentialsDeps>;
|
|
log: (...args: string[]) => void;
|
|
}
|
|
|
|
const defaultDeps: ConfigCommandDeps = {
|
|
configDeps: {},
|
|
log: (...args) => console.log(...args),
|
|
};
|
|
|
|
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?: ConfigApiDeps): Command {
|
|
const { configDeps, log } = { ...defaultDeps, ...deps };
|
|
|
|
const config = new Command('config').description('Manage mcpctl configuration');
|
|
|
|
config
|
|
.command('view')
|
|
.description('Show current configuration')
|
|
.option('-o, --output <format>', 'output format (json, yaml)', 'json')
|
|
.action((opts: { output: string }) => {
|
|
const cfg = loadConfig(configDeps);
|
|
const out = opts.output === 'yaml' ? formatYaml(cfg) : formatJson(cfg);
|
|
log(out);
|
|
});
|
|
|
|
config
|
|
.command('set')
|
|
.description('Set a configuration value')
|
|
.argument('<key>', 'configuration key (e.g., daemonUrl, outputFormat)')
|
|
.argument('<value>', 'value to set')
|
|
.action((key: string, value: string) => {
|
|
const updates: Record<string, unknown> = {};
|
|
|
|
// Handle typed conversions
|
|
if (key === 'cacheTTLMs') {
|
|
updates[key] = parseInt(value, 10);
|
|
} else if (key === 'registries') {
|
|
updates[key] = value.split(',').map((s) => s.trim());
|
|
} else if (key === 'daemonUrl') {
|
|
// Backward compat: map daemonUrl to mcplocalUrl
|
|
updates['mcplocalUrl'] = value;
|
|
} else {
|
|
updates[key] = value;
|
|
}
|
|
|
|
const updated = mergeConfig(updates as Partial<McpctlConfig>, configDeps);
|
|
saveConfig(updated, configDeps);
|
|
log(`Set ${key} = ${value}`);
|
|
});
|
|
|
|
config
|
|
.command('path')
|
|
.description('Show configuration file path')
|
|
.action(() => {
|
|
log(getConfigPath(configDeps?.configDir));
|
|
});
|
|
|
|
config
|
|
.command('reset')
|
|
.description('Reset configuration to defaults')
|
|
.action(() => {
|
|
saveConfig(DEFAULT_CONFIG, configDeps);
|
|
log('Configuration reset to defaults');
|
|
});
|
|
|
|
if (apiDeps) {
|
|
const { client, credentialsDeps, log: apiLog } = apiDeps;
|
|
|
|
config
|
|
.command('claude-generate')
|
|
.description('Generate .mcp.json from a project configuration')
|
|
.requiredOption('--project <name>', 'Project name')
|
|
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
|
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
|
.option('--stdout', 'Print to stdout instead of writing a file')
|
|
.action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
|
|
const mcpConfig = await client.get<McpConfig>(`/api/v1/projects/${opts.project}/mcp-config`);
|
|
|
|
if (opts.stdout) {
|
|
apiLog(JSON.stringify(mcpConfig, null, 2));
|
|
return;
|
|
}
|
|
|
|
const outputPath = resolve(opts.output);
|
|
let finalConfig = mcpConfig;
|
|
|
|
if (opts.merge && existsSync(outputPath)) {
|
|
try {
|
|
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
|
|
finalConfig = {
|
|
mcpServers: {
|
|
...existing.mcpServers,
|
|
...mcpConfig.mcpServers,
|
|
},
|
|
};
|
|
} catch {
|
|
// If existing file is invalid, just overwrite
|
|
}
|
|
}
|
|
|
|
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
|
|
const serverCount = Object.keys(finalConfig.mcpServers).length;
|
|
apiLog(`Wrote ${outputPath} (${serverCount} server(s))`);
|
|
});
|
|
|
|
config
|
|
.command('impersonate')
|
|
.description('Impersonate another user or return to original identity')
|
|
.argument('[email]', 'Email of user to impersonate')
|
|
.option('--quit', 'Stop impersonating and return to original identity')
|
|
.action(async (email: string | undefined, opts: { quit?: boolean }) => {
|
|
const configDir = credentialsDeps?.configDir ?? join(homedir(), '.mcpctl');
|
|
const backupPath = join(configDir, 'credentials-backup');
|
|
|
|
if (opts.quit) {
|
|
if (!existsSync(backupPath)) {
|
|
apiLog('No impersonation session to quit');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const backupRaw = readFileSync(backupPath, 'utf-8');
|
|
const backup = JSON.parse(backupRaw) as StoredCredentials;
|
|
saveCredentials(backup, credentialsDeps);
|
|
|
|
// Remove backup file
|
|
const { unlinkSync } = await import('node:fs');
|
|
unlinkSync(backupPath);
|
|
|
|
apiLog(`Returned to ${backup.user}`);
|
|
return;
|
|
}
|
|
|
|
if (!email) {
|
|
apiLog('Email is required when not using --quit');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Save current credentials as backup
|
|
const currentCreds = loadCredentials(credentialsDeps);
|
|
if (!currentCreds) {
|
|
apiLog('Not logged in. Run "mcpctl login" first.');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
writeFileSync(backupPath, JSON.stringify(currentCreds, null, 2) + '\n', 'utf-8');
|
|
|
|
try {
|
|
const result = await client.post<{ token: string; user: { email: string } }>(
|
|
'/api/v1/auth/impersonate',
|
|
{ email },
|
|
);
|
|
|
|
saveCredentials({
|
|
token: result.token,
|
|
mcpdUrl: currentCreds.mcpdUrl,
|
|
user: result.user.email,
|
|
}, credentialsDeps);
|
|
|
|
apiLog(`Impersonating ${result.user.email}. Use 'mcpctl config impersonate --quit' to return.`);
|
|
} catch (err) {
|
|
// Restore backup on failure
|
|
const backup = JSON.parse(readFileSync(backupPath, 'utf-8')) as StoredCredentials;
|
|
saveCredentials(backup, credentialsDeps);
|
|
const { unlinkSync } = await import('node:fs');
|
|
unlinkSync(backupPath);
|
|
|
|
apiLog(`Impersonate failed: ${(err as Error).message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
});
|
|
}
|
|
|
|
return config;
|
|
}
|