From 4f1b9cf024e97bd949ffa639cc81ea09c05b4a4d Mon Sep 17 00:00:00 2001
From: Dennis Postma <dennis@directonline.io>
Date: Sat, 28 Dec 2024 21:24:59 +0100
Subject: [PATCH] Renamed command manager to console manager, improved log
 reading

---
 src/application/console/commandRegistry.ts |  59 ++++++++++++
 src/application/console/consolePrompt.ts   |  33 +++++++
 src/application/console/logReader.ts       |  76 +++++++++++++++
 src/application/logger.ts                  |   3 +-
 src/application/types.ts                   |   8 +-
 src/managers/commandManager.ts             | 107 ---------------------
 src/managers/consoleManager.ts             |  56 +++++++++++
 src/server.ts                              |   8 +-
 8 files changed, 236 insertions(+), 114 deletions(-)
 create mode 100644 src/application/console/commandRegistry.ts
 create mode 100644 src/application/console/consolePrompt.ts
 create mode 100644 src/application/console/logReader.ts
 delete mode 100644 src/managers/commandManager.ts
 create mode 100644 src/managers/consoleManager.ts

diff --git a/src/application/console/commandRegistry.ts b/src/application/console/commandRegistry.ts
new file mode 100644
index 0000000..54e3f04
--- /dev/null
+++ b/src/application/console/commandRegistry.ts
@@ -0,0 +1,59 @@
+import * as fs from 'fs'
+import * as path from 'path'
+import Logger, { LoggerType } from '#application/logger'
+import { getAppPath } from '#application/storage'
+import { Command } from '#application/types'
+
+export class CommandRegistry {
+  private readonly commands: Map<string, Command> = new Map()
+  private readonly logger = Logger.type(LoggerType.COMMAND)
+
+  public getCommand(name: string): Command | undefined {
+    return this.commands.get(name)
+  }
+
+  public async loadCommands(): Promise<void> {
+    const directory = getAppPath('commands')
+    this.logger.info(`Loading commands from: ${directory}`)
+
+    try {
+      const files = await fs.promises.readdir(directory, { withFileTypes: true })
+      await Promise.all(
+        files
+          .filter(file => this.isValidCommandFile(file))
+          .map(file => this.loadCommandFile(file))
+      )
+    } catch (error) {
+      this.logger.error(`Failed to read commands directory: ${error instanceof Error ? error.message : String(error)}`)
+    }
+  }
+
+  private isValidCommandFile(file: fs.Dirent): boolean {
+    return file.isFile() && (file.name.endsWith('.ts') || file.name.endsWith('.js'))
+  }
+
+  private async loadCommandFile(file: fs.Dirent): Promise<void> {
+    const fullPath = getAppPath('commands', file.name)
+    const commandName = path.basename(file.name, path.extname(file.name))
+
+    try {
+      const module = await import(fullPath)
+      if (typeof module.default !== 'function') {
+        this.logger.warn(`Unrecognized export in ${file.name}`)
+        return
+      }
+
+      this.registerCommand(commandName, module.default)
+    } catch (error) {
+      this.logger.error(`Error loading command ${file.name}: ${error instanceof Error ? error.message : String(error)}`)
+    }
+  }
+
+  private registerCommand(name: string, CommandClass: Command): void {
+    if (this.commands.has(name)) {
+      this.logger.warn(`Command '${name}' is already registered. Overwriting...`)
+    }
+    this.commands.set(name, CommandClass)
+    this.logger.info(`Registered command: ${name}`)
+  }
+}
\ No newline at end of file
diff --git a/src/application/console/consolePrompt.ts b/src/application/console/consolePrompt.ts
new file mode 100644
index 0000000..129b20c
--- /dev/null
+++ b/src/application/console/consolePrompt.ts
@@ -0,0 +1,33 @@
+import * as readline from 'readline'
+
+export class ConsolePrompt {
+  private readonly rl: readline.Interface
+  private isClosed: boolean = false
+
+  constructor(private readonly commandHandler: (command: string) => void) {
+    this.rl = readline.createInterface({
+      input: process.stdin,
+      output: process.stdout
+    })
+
+    this.rl.on('close', () => {
+      this.isClosed = true
+    })
+  }
+
+  public start(): void {
+    if (this.isClosed) return
+    this.promptCommand()
+  }
+
+  public close(): void {
+    this.rl.close()
+  }
+
+  private promptCommand(): void {
+    this.rl.question('> ', (command: string) => {
+      this.commandHandler(command)
+      this.promptCommand()
+    })
+  }
+}
\ No newline at end of file
diff --git a/src/application/console/logReader.ts b/src/application/console/logReader.ts
new file mode 100644
index 0000000..e48b23b
--- /dev/null
+++ b/src/application/console/logReader.ts
@@ -0,0 +1,76 @@
+import * as fs from 'fs'
+import * as path from 'path'
+import Logger, { LoggerType } from '#application/logger'
+
+export class LogReader {
+  private logger = Logger.type(LoggerType.CONSOLE)
+  private watchers: fs.FSWatcher[] = []
+  private readonly logsDirectory: string
+
+  constructor(rootPath: string) {
+    this.logsDirectory = path.join(rootPath, 'logs')
+  }
+
+  public start(): void {
+    this.logger.info('Starting log reader...')
+    this.watchLogs()
+  }
+
+  public stop(): void {
+    this.watchers.forEach(watcher => watcher.close())
+    this.watchers = []
+  }
+
+  private watchLogs(): void {
+    // Watch directory for new files
+    const directoryWatcher = fs.watch(this.logsDirectory, (_, filename) => {
+      if (filename?.endsWith('.log')) {
+        this.watchLogFile(filename)
+      }
+    })
+    this.watchers.push(directoryWatcher)
+
+    // Watch existing files
+    try {
+      fs.readdirSync(this.logsDirectory)
+        .filter(file => file.endsWith('.log'))
+        .forEach(file => this.watchLogFile(file))
+    } catch (error) {
+      this.logger.error(`Error reading logs directory: ${error}`)
+    }
+  }
+
+  private watchLogFile(filename: string): void {
+    const filePath = path.join(this.logsDirectory, filename)
+    let currentPosition = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0
+
+    const watcher = fs.watch(filePath, () => {
+      try {
+        const stat = fs.statSync(filePath)
+        const newPosition = stat.size
+
+        if (newPosition < currentPosition) {
+          currentPosition = 0
+        }
+
+        if (newPosition > currentPosition) {
+          const stream = fs.createReadStream(filePath, {
+            start: currentPosition,
+            end: newPosition
+          })
+
+          stream.on('data', (data) => {
+            process.stdout.write('\r' + `[${filename}]\n${data}`)
+            process.stdout.write('\n> ')
+          })
+
+          currentPosition = newPosition
+        }
+      } catch {
+        watcher.close()
+      }
+    })
+
+    this.watchers.push(watcher)
+  }
+}
\ No newline at end of file
diff --git a/src/application/logger.ts b/src/application/logger.ts
index 2202d5c..e7817b5 100644
--- a/src/application/logger.ts
+++ b/src/application/logger.ts
@@ -8,7 +8,8 @@ export enum LoggerType {
   QUEUE = 'queue',
   COMMAND = 'command',
   REPOSITORY = 'repository',
-  ENTITY = 'entity'
+  ENTITY = 'entity',
+  CONSOLE = 'console'
 }
 
 class Logger {
diff --git a/src/application/types.ts b/src/application/types.ts
index 2ebd239..3c9e6c7 100644
--- a/src/application/types.ts
+++ b/src/application/types.ts
@@ -1,4 +1,4 @@
-import { Socket } from 'socket.io'
+import { Server, Socket } from 'socket.io'
 
 import { Character } from '#entities/character'
 import { ZoneEventTile } from '#entities/zoneEventTile'
@@ -51,6 +51,12 @@ export type WorldSettings = {
   fogDensity: number
 }
 
+export interface Command {
+  new (io: Server): {
+    execute(args: string[]): Promise<void>
+  }
+}
+
 // export type TCharacter = Socket & {
 //   user?: User
 //   character?: Character
diff --git a/src/managers/commandManager.ts b/src/managers/commandManager.ts
deleted file mode 100644
index 4119935..0000000
--- a/src/managers/commandManager.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import * as fs from 'fs'
-import * as path from 'path'
-import * as readline from 'readline'
-
-import { Server } from 'socket.io'
-
-import Logger, { LoggerType } from '#application/logger'
-import { getAppPath } from '#application/storage'
-
-class CommandManager {
-  private logger = Logger.type(LoggerType.COMMAND)
-  private commands: Map<string, any> = new Map()
-  private rl: readline.Interface
-  private io: Server | null = null
-  private rlClosed: boolean = false
-
-  constructor() {
-    this.rl = readline.createInterface({
-      input: process.stdin,
-      output: process.stdout
-    })
-
-    this.rl.on('close', () => {
-      this.rlClosed = true
-    })
-  }
-
-  public async boot(io: Server) {
-    this.io = io
-    await this.loadCommands()
-    this.logger.info('Command manager loaded')
-    this.startPrompt()
-  }
-
-  private startPrompt() {
-    if (this.rlClosed) return
-
-    this.rl.question('> ', (command: string) => {
-      this.processCommand(command)
-      this.startPrompt()
-    })
-  }
-
-  private async processCommand(command: string): Promise<void> {
-    const [cmd, ...args] = command.trim().split(' ')
-    if (this.commands.has(cmd)) {
-      const CommandClass = this.commands.get(cmd)
-      const commandInstance = new CommandClass(this.io as Server)
-      await commandInstance.execute(args)
-    } else {
-      this.handleUnknownCommand(cmd)
-    }
-  }
-
-  private handleUnknownCommand(command: string) {
-    switch (command) {
-      case 'exit':
-        this.rl.close()
-        break
-      default:
-        console.error(`Unknown command: ${command}`)
-        break
-    }
-  }
-
-  private async loadCommands() {
-    const directory = getAppPath('commands')
-    this.logger.info(`Loading commands from: ${directory}`)
-
-    try {
-      const files = await fs.promises.readdir(directory, { withFileTypes: true })
-
-      for (const file of files) {
-        if (!file.isFile() || (!file.name.endsWith('.ts') && !file.name.endsWith('.js'))) {
-          continue
-        }
-
-        const fullPath = getAppPath('commands', file.name)
-        const commandName = path.basename(file.name, path.extname(file.name))
-
-        try {
-          const module = await import(fullPath)
-          if (typeof module.default !== 'function') {
-            this.logger.warn(`Unrecognized export in ${file.name}`)
-            continue
-          }
-
-          this.registerCommand(commandName, module.default)
-        } catch (error) {
-          this.logger.error(`Error loading command ${file.name}: ${error instanceof Error ? error.message : String(error)}`)
-        }
-      }
-    } catch (error) {
-      this.logger.error(`Failed to read commands directory: ${error instanceof Error ? error.message : String(error)}`)
-    }
-  }
-
-  private registerCommand(name: string, CommandClass: any) {
-    if (this.commands.has(name)) {
-      this.logger.warn(`Command '${name}' is already registered. Overwriting...`)
-    }
-    this.commands.set(name, CommandClass)
-    this.logger.info(`Registered command: ${name}`)
-  }
-}
-
-export default new CommandManager()
diff --git a/src/managers/consoleManager.ts b/src/managers/consoleManager.ts
new file mode 100644
index 0000000..4d908e5
--- /dev/null
+++ b/src/managers/consoleManager.ts
@@ -0,0 +1,56 @@
+import { Server } from 'socket.io'
+import { CommandRegistry } from '#application/console/commandRegistry'
+import { ConsolePrompt } from '#application/console/consolePrompt'
+import { LogReader } from '#application/console/logReader'
+import Logger, { LoggerType } from '#application/logger'
+
+export class ConsoleManager {
+  private readonly logger = Logger.type(LoggerType.COMMAND)
+  private readonly registry: CommandRegistry
+  private readonly prompt: ConsolePrompt
+  private readonly logReader: LogReader
+  private io: Server | null = null
+
+  constructor() {
+    this.registry = new CommandRegistry()
+    this.prompt = new ConsolePrompt(
+      (command: string) => this.processCommand(command)
+    )
+
+    this.logReader = new LogReader(process.cwd())
+  }
+
+  public async boot(io: Server): Promise<void> {
+    this.io = io
+
+    await this.registry.loadCommands()
+    this.logReader.start()
+    this.prompt.start()
+
+    this.logger.info('Console manager loaded')
+  }
+
+  private async processCommand(commandLine: string): Promise<void> {
+    const [cmd, ...args] = commandLine.trim().split(' ')
+
+    if (cmd === 'exit') {
+      this.prompt.close()
+      return
+    }
+
+    const CommandClass = this.registry.getCommand(cmd)
+    if (!CommandClass) {
+      console.error(`Unknown command: ${cmd}`)
+      return
+    }
+
+    try {
+      const commandInstance = new CommandClass(this.io as Server)
+      await commandInstance.execute(args)
+    } catch (error) {
+      this.logger.error(`Error executing command ${cmd}: ${error instanceof Error ? error.message : String(error)}`)
+    }
+  }
+}
+
+export default new ConsoleManager()
\ No newline at end of file
diff --git a/src/server.ts b/src/server.ts
index 52fe6a0..f713b80 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -11,7 +11,7 @@ import Logger, { LoggerType } from '#application/logger'
 import { getAppPath } from '#application/storage'
 import { TSocket } from '#application/types'
 import { HttpRouter } from '#http/router'
-import CommandManager from '#managers/commandManager'
+import ConsoleManager from '#managers/consoleManager'
 import DateManager from '#managers/dateManager'
 import QueueManager from '#managers/queueManager'
 import UserManager from '#managers/userManager'
@@ -49,8 +49,6 @@ export class Server {
    * Start the server
    */
   public async start() {
-    // Read log file and print to console for debugging
-
     // Connect to database
     try {
       await Database.initialize()
@@ -86,8 +84,8 @@ export class Server {
     // Load zoneEditor manager
     await ZoneManager.boot()
 
-    // Load command manager
-    await CommandManager.boot(this.io)
+    // Load console manager
+    await ConsoleManager.boot(this.io)
 
     // Listen for socket connections
     this.io.on('connection', this.handleConnection.bind(this))