ursamu-mud
Version:
Ursamu - Modular MUD Engine with sandboxed scripting and plugin system
789 lines • 29.7 kB
JavaScript
/**
* Interactive debugging commands
*/
import { WebSocket } from 'ws';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { BaseCommand } from './BaseCommand.js';
import { RestClient } from '../clients/RestClient.js';
export class DebugCommands extends BaseCommand {
restClient;
activeSession;
debugHistory = [];
constructor(config) {
super(config);
this.restClient = new RestClient(config);
}
register(program) {
const debug = program
.command('debug')
.description('Interactive debugging commands');
// Start debug session
debug
.command('start')
.description('Start debugging session for script')
.argument('<script>', 'script name')
.option('-b, --breakpoint <line>', 'set initial breakpoint')
.option('-c, --condition <expr>', 'breakpoint condition')
.option('--entry', 'break on script entry')
.action(async (scriptName, options) => {
await this.handleCommand(() => this.startDebugging(scriptName, options));
});
// Stop debug session
debug
.command('stop')
.description('Stop current debugging session')
.action(async () => {
await this.handleCommand(() => this.stopDebugging());
});
// Set breakpoint
debug
.command('break')
.alias('bp')
.description('Set breakpoint')
.argument('<line>', 'line number')
.option('-c, --condition <expr>', 'breakpoint condition')
.option('-f, --file <file>', 'source file (if different)')
.action(async (line, options) => {
await this.handleCommand(() => this.setBreakpoint(parseInt(line), options));
});
// Remove breakpoint
debug
.command('clear')
.description('Clear breakpoint')
.argument('<id>', 'breakpoint ID or line number')
.action(async (identifier) => {
await this.handleCommand(() => this.clearBreakpoint(identifier));
});
// List breakpoints
debug
.command('list')
.alias('ls')
.description('List all breakpoints')
.action(async () => {
await this.handleCommand(() => this.listBreakpoints());
});
// Continue execution
debug
.command('continue')
.alias('c')
.description('Continue execution')
.action(async () => {
await this.handleCommand(() => this.continueExecution());
});
// Step into
debug
.command('step')
.alias('s')
.description('Step into next statement')
.action(async () => {
await this.handleCommand(() => this.stepInto());
});
// Step over
debug
.command('next')
.alias('n')
.description('Step over next statement')
.action(async () => {
await this.handleCommand(() => this.stepOver());
});
// Step out
debug
.command('out')
.alias('o')
.description('Step out of current function')
.action(async () => {
await this.handleCommand(() => this.stepOut());
});
// Show stack trace
debug
.command('stack')
.description('Show call stack')
.option('-v, --verbose', 'show variables')
.action(async (options) => {
await this.handleCommand(() => this.showStack(options));
});
// Inspect variables
debug
.command('vars')
.description('Show variables in current scope')
.argument('[pattern]', 'variable name pattern')
.action(async (pattern) => {
await this.handleCommand(() => this.showVariables(pattern));
});
// Evaluate expression
debug
.command('eval')
.description('Evaluate expression in current context')
.argument('<expression>', 'expression to evaluate')
.action(async (expression) => {
await this.handleCommand(() => this.evaluateExpression(expression));
});
// Watch variable
debug
.command('watch')
.description('Watch variable for changes')
.argument('<variable>', 'variable name')
.action(async (variable) => {
await this.handleCommand(() => this.watchVariable(variable));
});
// Interactive debug mode
debug
.command('interactive')
.alias('i')
.description('Enter interactive debugging mode')
.argument('[script]', 'script to debug')
.action(async (scriptName) => {
await this.handleCommand(() => this.interactiveMode(scriptName));
});
// Show debug history
debug
.command('history')
.description('Show debug session history')
.option('-n, --lines <count>', 'number of entries to show', '20')
.action(async (options) => {
await this.handleCommand(() => this.showHistory(parseInt(options.lines)));
});
}
async startDebugging(scriptName, options = {}) {
if (this.activeSession) {
this.warn('Debug session already active. Stop current session first.');
return;
}
const spinner = this.startSpinner('Starting debug session...');
try {
// Start debug session with server
this.updateSpinner('Connecting to debug server...');
const result = await this.restClient.startDebugSession(scriptName);
if (!result.success || !result.sessionId) {
this.failSpinner('Failed to start debug session');
return;
}
// Connect WebSocket for debugging
this.updateSpinner('Establishing debug connection...');
const websocket = await this.connectDebugWebSocket(result.sessionId);
// Create debug session
this.activeSession = {
sessionId: result.sessionId,
scriptName,
websocket,
breakpoints: new Map(),
callStack: [],
variables: {},
status: 'running'
};
// Set initial breakpoint if requested
if (options.breakpoint) {
const line = parseInt(options.breakpoint);
await this.setBreakpoint(line, { condition: options.condition });
}
if (options.entry) {
await this.setBreakpoint(1, { condition: null });
}
this.succeedSpinner(`Debug session started: ${chalk.green(scriptName)}`);
console.log(chalk.gray(`Session ID: ${result.sessionId}`));
console.log(chalk.gray('Use `mud debug interactive` to enter interactive mode'));
}
catch (error) {
this.failSpinner('Failed to start debugging');
throw error;
}
}
async stopDebugging() {
if (!this.activeSession) {
this.warn('No active debug session');
return;
}
const spinner = this.startSpinner('Stopping debug session...');
try {
// Close WebSocket connection
if (this.activeSession.websocket.readyState === WebSocket.OPEN) {
this.activeSession.websocket.close();
}
// Stop server-side session
await this.restClient.stopDebugSession(this.activeSession.sessionId);
this.succeedSpinner(`Debug session stopped: ${chalk.yellow(this.activeSession.scriptName)}`);
this.activeSession = undefined;
}
catch (error) {
this.failSpinner('Error stopping debug session');
this.activeSession = undefined;
throw error;
}
}
async setBreakpoint(line, options = {}) {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
const spinner = this.startSpinner(`Setting breakpoint at line ${line}...`);
try {
const breakpointId = `bp_${line}_${Date.now()}`;
// Send breakpoint to debugger
const message = {
type: 'set_breakpoint',
data: {
id: breakpointId,
line,
condition: options.condition,
file: options.file
}
};
this.sendDebugMessage(message);
// Store breakpoint info
const breakpoint = {
id: breakpointId,
line,
condition: options.condition,
enabled: true,
hitCount: 0
};
this.activeSession.breakpoints.set(breakpointId, breakpoint);
this.succeedSpinner(`Breakpoint set at line ${chalk.yellow(line.toString())}`);
if (options.condition) {
console.log(chalk.gray(`Condition: ${options.condition}`));
}
}
catch (error) {
this.failSpinner('Failed to set breakpoint');
throw error;
}
}
async clearBreakpoint(identifier) {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
const spinner = this.startSpinner('Clearing breakpoint...');
try {
let breakpointId;
// Find breakpoint by ID or line number
if (this.activeSession.breakpoints.has(identifier)) {
breakpointId = identifier;
}
else {
// Search by line number
const line = parseInt(identifier);
if (!isNaN(line)) {
for (const [id, bp] of this.activeSession.breakpoints.entries()) {
if (bp.line === line) {
breakpointId = id;
break;
}
}
}
}
if (!breakpointId) {
this.failSpinner('Breakpoint not found');
return;
}
const breakpoint = this.activeSession.breakpoints.get(breakpointId);
// Send clear message to debugger
this.sendDebugMessage({
type: 'clear_breakpoint',
data: { id: breakpointId }
});
// Remove from local storage
this.activeSession.breakpoints.delete(breakpointId);
this.succeedSpinner(`Breakpoint cleared at line ${chalk.yellow(breakpoint.line.toString())}`);
}
catch (error) {
this.failSpinner('Failed to clear breakpoint');
throw error;
}
}
async listBreakpoints() {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
if (this.activeSession.breakpoints.size === 0) {
console.log(chalk.yellow('No breakpoints set'));
return;
}
console.log(chalk.bold('Breakpoints:'));
console.log('ID'.padEnd(20) + 'Line'.padEnd(8) + 'Hits'.padEnd(8) + 'Condition');
console.log('─'.repeat(60));
for (const [id, bp] of this.activeSession.breakpoints.entries()) {
const status = bp.enabled ? chalk.green('●') : chalk.red('○');
const condition = bp.condition || chalk.gray('none');
console.log(`${status} ${id.slice(0, 16).padEnd(16)} ` +
`${bp.line.toString().padEnd(6)} ` +
`${bp.hitCount.toString().padEnd(6)} ` +
`${condition}`);
}
}
async continueExecution() {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
this.sendDebugMessage({
type: 'continue',
data: {}
});
this.activeSession.status = 'running';
console.log(chalk.blue('Continuing execution...'));
}
async stepInto() {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
this.sendDebugMessage({
type: 'step_into',
data: {}
});
console.log(chalk.blue('Stepping into...'));
}
async stepOver() {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
this.sendDebugMessage({
type: 'step_over',
data: {}
});
console.log(chalk.blue('Stepping over...'));
}
async stepOut() {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
this.sendDebugMessage({
type: 'step_out',
data: {}
});
console.log(chalk.blue('Stepping out...'));
}
async showStack(options = {}) {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
if (this.activeSession.callStack.length === 0) {
console.log(chalk.yellow('No call stack available'));
return;
}
console.log(chalk.bold('Call Stack:'));
for (let i = 0; i < this.activeSession.callStack.length; i++) {
const frame = this.activeSession.callStack[i];
const current = i === 0 ? chalk.yellow('→ ') : ' ';
console.log(`${current}${i}: ${chalk.cyan(frame.functionName)} ` +
`${chalk.gray(`${frame.fileName}:${frame.line}:${frame.column}`)}`);
if (options.verbose && frame.variables) {
const vars = Object.entries(frame.variables);
if (vars.length > 0) {
for (const [name, value] of vars) {
console.log(` ${name}: ${this.formatValue(value)}`);
}
}
}
}
}
async showVariables(pattern) {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
const variables = this.activeSession.variables;
let filteredVars = Object.entries(variables);
if (pattern) {
const regex = new RegExp(pattern, 'i');
filteredVars = filteredVars.filter(([name]) => regex.test(name));
}
if (filteredVars.length === 0) {
console.log(chalk.yellow(pattern ? 'No matching variables' : 'No variables in scope'));
return;
}
console.log(chalk.bold('Variables:'));
for (const [name, value] of filteredVars) {
console.log(` ${chalk.cyan(name)}: ${this.formatValue(value)}`);
}
}
async evaluateExpression(expression) {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
const spinner = this.startSpinner('Evaluating expression...');
try {
// Send evaluation request
this.sendDebugMessage({
type: 'evaluate',
data: { expression }
});
// Wait for response (this is simplified - real implementation would use promises)
this.succeedSpinner('Expression evaluated (check debug output)');
}
catch (error) {
this.failSpinner('Failed to evaluate expression');
throw error;
}
}
async watchVariable(variable) {
if (!this.activeSession) {
this.error('No active debug session');
return;
}
this.sendDebugMessage({
type: 'watch_variable',
data: { variable }
});
console.log(chalk.blue(`Watching variable: ${chalk.cyan(variable)}`));
}
async interactiveMode(scriptName) {
if (!scriptName && !this.activeSession) {
this.error('No active debug session. Specify script name to start new session.');
return;
}
if (scriptName && !this.activeSession) {
await this.startDebugging(scriptName, {});
}
if (!this.activeSession) {
return;
}
console.log(chalk.blue('🐛 Entering interactive debug mode'));
console.log(chalk.gray('Type "help" for commands, "exit" to leave interactive mode'));
let inInteractiveMode = true;
while (inInteractiveMode) {
try {
const { command } = await inquirer.prompt([
{
type: 'input',
name: 'command',
message: chalk.blue('debug>'),
prefix: ''
}
]);
const trimmed = command.trim();
if (!trimmed)
continue;
if (trimmed === 'exit' || trimmed === 'quit') {
inInteractiveMode = false;
continue;
}
if (trimmed === 'help') {
this.showInteractiveHelp();
continue;
}
// Parse and execute debug command
await this.executeInteractiveCommand(trimmed);
}
catch (error) {
if (error.message.includes('User force closed')) {
inInteractiveMode = false;
}
else {
console.error(chalk.red('Interactive mode error:'), error.message);
}
}
}
console.log(chalk.blue('👋 Exited interactive debug mode'));
}
async executeInteractiveCommand(command) {
const parts = command.split(/\s+/);
const cmd = parts[0];
const args = parts.slice(1);
switch (cmd) {
case 'c':
case 'continue':
await this.continueExecution();
break;
case 's':
case 'step':
await this.stepInto();
break;
case 'n':
case 'next':
await this.stepOver();
break;
case 'o':
case 'out':
await this.stepOut();
break;
case 'bp':
case 'break':
if (args.length > 0) {
const line = parseInt(args[0]);
const condition = args.slice(1).join(' ') || undefined;
await this.setBreakpoint(line, { condition });
}
else {
console.log(chalk.red('Usage: break <line> [condition]'));
}
break;
case 'clear':
if (args.length > 0) {
await this.clearBreakpoint(args[0]);
}
else {
console.log(chalk.red('Usage: clear <breakpoint-id>'));
}
break;
case 'ls':
case 'list':
await this.listBreakpoints();
break;
case 'stack':
await this.showStack({ verbose: args.includes('-v') });
break;
case 'vars':
await this.showVariables(args[0]);
break;
case 'eval':
if (args.length > 0) {
const expression = args.join(' ');
await this.evaluateExpression(expression);
}
else {
console.log(chalk.red('Usage: eval <expression>'));
}
break;
case 'watch':
if (args.length > 0) {
await this.watchVariable(args[0]);
}
else {
console.log(chalk.red('Usage: watch <variable>'));
}
break;
case 'status':
this.showSessionStatus();
break;
default:
console.log(chalk.red(`Unknown command: ${cmd}. Type "help" for available commands.`));
}
}
showInteractiveHelp() {
console.log(chalk.bold('\nAvailable Commands:'));
console.log(chalk.cyan(' c, continue') + ' - Continue execution');
console.log(chalk.cyan(' s, step') + ' - Step into next statement');
console.log(chalk.cyan(' n, next') + ' - Step over next statement');
console.log(chalk.cyan(' o, out') + ' - Step out of current function');
console.log(chalk.cyan(' bp, break <line>') + ' - Set breakpoint');
console.log(chalk.cyan(' clear <id>') + ' - Clear breakpoint');
console.log(chalk.cyan(' ls, list') + ' - List breakpoints');
console.log(chalk.cyan(' stack') + ' - Show call stack');
console.log(chalk.cyan(' vars [pattern]') + ' - Show variables');
console.log(chalk.cyan(' eval <expr>') + ' - Evaluate expression');
console.log(chalk.cyan(' watch <var>') + ' - Watch variable');
console.log(chalk.cyan(' status') + ' - Show session status');
console.log(chalk.cyan(' help') + ' - Show this help');
console.log(chalk.cyan(' exit, quit') + ' - Exit interactive mode');
console.log();
}
showSessionStatus() {
if (!this.activeSession) {
console.log(chalk.red('No active debug session'));
return;
}
console.log(chalk.bold('Debug Session Status:'));
console.log(` Script: ${chalk.cyan(this.activeSession.scriptName)}`);
console.log(` Session ID: ${chalk.gray(this.activeSession.sessionId)}`);
console.log(` Status: ${this.formatStatus(this.activeSession.status)}`);
console.log(` Breakpoints: ${this.activeSession.breakpoints.size}`);
console.log(` Call Stack Depth: ${this.activeSession.callStack.length}`);
const wsStatus = this.getWebSocketStatus();
console.log(` Connection: ${wsStatus}`);
}
async showHistory(maxEntries) {
if (this.debugHistory.length === 0) {
console.log(chalk.yellow('No debug history'));
return;
}
const entries = this.debugHistory.slice(-maxEntries);
console.log(chalk.bold('Debug History:'));
for (const event of entries) {
const timestamp = new Date(event.timestamp).toLocaleTimeString();
const type = this.formatEventType(event.type);
console.log(`${chalk.gray(timestamp)} ${type} ${this.formatEventData(event.data)}`);
}
}
async connectDebugWebSocket(sessionId) {
return new Promise((resolve, reject) => {
const url = this.config.getDebuggerUrl() + `/${sessionId}`;
const ws = new WebSocket(url);
ws.on('open', () => {
this.verbose('Debug WebSocket connected');
resolve(ws);
});
ws.on('error', (error) => {
reject(new Error(`WebSocket connection failed: ${error.message}`));
});
ws.on('message', (data) => {
this.handleDebugMessage(JSON.parse(data.toString()));
});
ws.on('close', () => {
this.verbose('Debug WebSocket disconnected');
});
// Connection timeout
setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
ws.close();
reject(new Error('WebSocket connection timeout'));
}
}, 10000);
});
}
sendDebugMessage(message) {
if (!this.activeSession || this.activeSession.websocket.readyState !== WebSocket.OPEN) {
this.error('Debug connection not available');
return;
}
this.activeSession.websocket.send(JSON.stringify(message));
}
handleDebugMessage(message) {
if (!this.activeSession)
return;
const event = {
type: message.type,
data: message.data,
timestamp: Date.now()
};
this.debugHistory.push(event);
switch (message.type) {
case 'breakpoint_hit':
this.handleBreakpointHit(message.data);
break;
case 'step_complete':
this.handleStepComplete(message.data);
break;
case 'variable_changed':
this.handleVariableChanged(message.data);
break;
case 'error':
this.handleDebugError(message.data);
break;
case 'execution_complete':
this.handleExecutionComplete(message.data);
break;
}
}
handleBreakpointHit(data) {
if (!this.activeSession)
return;
this.activeSession.status = 'paused';
console.log(chalk.yellow('\n🔍 Breakpoint hit!'));
console.log(chalk.gray(` Line: ${data.line}`));
console.log(chalk.gray(` Function: ${data.functionName || 'global'}`));
if (data.condition) {
console.log(chalk.gray(` Condition: ${data.condition}`));
}
// Update breakpoint hit count
const breakpoint = Array.from(this.activeSession.breakpoints.values())
.find(bp => bp.line === data.line);
if (breakpoint) {
breakpoint.hitCount++;
}
// Update call stack and variables
if (data.callStack) {
this.activeSession.callStack = data.callStack;
}
if (data.variables) {
this.activeSession.variables = data.variables;
}
}
handleStepComplete(data) {
console.log(chalk.blue('Step completed'));
if (data.line) {
console.log(chalk.gray(` Current line: ${data.line}`));
}
}
handleVariableChanged(data) {
console.log(chalk.magenta('Variable changed:'));
console.log(` ${chalk.cyan(data.name)}: ${this.formatValue(data.oldValue)} → ${this.formatValue(data.newValue)}`);
}
handleDebugError(data) {
console.log(chalk.red('Debug error:'), data.message);
if (data.stack) {
console.log(chalk.gray(data.stack));
}
}
handleExecutionComplete(data) {
if (!this.activeSession)
return;
this.activeSession.status = 'stopped';
console.log(chalk.green('\n✅ Script execution completed'));
if (data.result) {
console.log('Result:', this.formatValue(data.result));
}
if (data.metrics) {
console.log(chalk.gray(`Execution time: ${data.metrics.executionTime}ms`));
console.log(chalk.gray(`Instructions: ${data.metrics.instructionsExecuted}`));
}
}
formatValue(value) {
if (value === null)
return chalk.gray('null');
if (value === undefined)
return chalk.gray('undefined');
if (typeof value === 'string')
return chalk.green(`"${value}"`);
if (typeof value === 'number')
return chalk.yellow(value.toString());
if (typeof value === 'boolean')
return chalk.cyan(value.toString());
if (typeof value === 'object') {
try {
return chalk.white(JSON.stringify(value, null, 2));
}
catch {
return chalk.gray('[Object]');
}
}
return String(value);
}
formatStatus(status) {
switch (status) {
case 'running':
return chalk.green(status);
case 'paused':
return chalk.yellow(status);
case 'stopped':
return chalk.red(status);
default:
return chalk.gray(status);
}
}
getWebSocketStatus() {
if (!this.activeSession) {
return chalk.red('none');
}
switch (this.activeSession.websocket.readyState) {
case WebSocket.CONNECTING:
return chalk.yellow('connecting');
case WebSocket.OPEN:
return chalk.green('connected');
case WebSocket.CLOSING:
return chalk.yellow('closing');
case WebSocket.CLOSED:
return chalk.red('closed');
default:
return chalk.gray('unknown');
}
}
formatEventType(type) {
const colors = {
breakpoint_hit: 'yellow',
step_complete: 'blue',
variable_changed: 'magenta',
error: 'red',
execution_complete: 'green'
};
const color = colors[type] || 'gray';
return chalk[color](type);
}
formatEventData(data) {
if (data.line)
return `line ${data.line}`;
if (data.name)
return data.name;
if (data.message)
return data.message;
return JSON.stringify(data);
}
}
//# sourceMappingURL=DebugCommands.js.map