morpheus-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +129 -0
  2. package/bin/morpheus.js +19 -0
  3. package/dist/channels/__tests__/telegram.test.js +63 -0
  4. package/dist/channels/telegram.js +89 -0
  5. package/dist/cli/commands/config.js +81 -0
  6. package/dist/cli/commands/doctor.js +69 -0
  7. package/dist/cli/commands/init.js +108 -0
  8. package/dist/cli/commands/start.js +145 -0
  9. package/dist/cli/commands/status.js +21 -0
  10. package/dist/cli/commands/stop.js +28 -0
  11. package/dist/cli/index.js +38 -0
  12. package/dist/cli/utils/render.js +11 -0
  13. package/dist/config/__tests__/manager.test.js +11 -0
  14. package/dist/config/manager.js +55 -0
  15. package/dist/config/paths.js +15 -0
  16. package/dist/config/schemas.js +35 -0
  17. package/dist/config/utils.js +21 -0
  18. package/dist/http/__tests__/config_api.test.js +79 -0
  19. package/dist/http/api.js +134 -0
  20. package/dist/http/server.js +52 -0
  21. package/dist/runtime/__tests__/agent.test.js +91 -0
  22. package/dist/runtime/__tests__/agent_persistence.test.js +148 -0
  23. package/dist/runtime/__tests__/display.test.js +135 -0
  24. package/dist/runtime/__tests__/manual_start_verify.js +42 -0
  25. package/dist/runtime/__tests__/manual_us1.js +33 -0
  26. package/dist/runtime/agent.js +84 -0
  27. package/dist/runtime/display.js +146 -0
  28. package/dist/runtime/errors.js +16 -0
  29. package/dist/runtime/lifecycle.js +41 -0
  30. package/dist/runtime/memory/__tests__/sqlite.test.js +179 -0
  31. package/dist/runtime/memory/sqlite.js +192 -0
  32. package/dist/runtime/providers/factory.js +62 -0
  33. package/dist/runtime/scaffold.js +31 -0
  34. package/dist/runtime/types.js +1 -0
  35. package/dist/types/config.js +24 -0
  36. package/dist/types/display.js +1 -0
  37. package/dist/ui/assets/index-nNle8n-Z.css +1 -0
  38. package/dist/ui/assets/index-ySbKLOXZ.js +50 -0
  39. package/dist/ui/index.html +14 -0
  40. package/dist/ui/vite.svg +31 -0
  41. package/package.json +62 -0
@@ -0,0 +1,145 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import { scaffold } from '../../runtime/scaffold.js';
5
+ import { DisplayManager } from '../../runtime/display.js';
6
+ import { writePid, readPid, isProcessRunning, clearPid, checkStalePid } from '../../runtime/lifecycle.js';
7
+ import { ConfigManager } from '../../config/manager.js';
8
+ import { renderBanner } from '../utils/render.js';
9
+ import { TelegramAdapter } from '../../channels/telegram.js';
10
+ import { PATHS } from '../../config/paths.js';
11
+ import { Agent } from '../../runtime/agent.js';
12
+ import { ProviderError } from '../../runtime/errors.js';
13
+ import { HttpServer } from '../../http/server.js';
14
+ export const startCommand = new Command('start')
15
+ .description('Start the Morpheus agent')
16
+ .option('--ui', 'Enable web UI', true)
17
+ .option('--no-ui', 'Disable web UI')
18
+ .option('-p, --port <number>', 'Port for web UI', '3333')
19
+ .action(async (options) => {
20
+ const display = DisplayManager.getInstance();
21
+ try {
22
+ renderBanner();
23
+ await scaffold(); // Ensure env exists
24
+ // Cleanup stale PID first
25
+ await checkStalePid();
26
+ const existingPid = await readPid();
27
+ if (existingPid !== null && isProcessRunning(existingPid)) {
28
+ display.log(chalk.red(`Morpheus is already running (PID: ${existingPid})`));
29
+ process.exit(1);
30
+ }
31
+ // Check config existence
32
+ if (!await fs.pathExists(PATHS.config)) {
33
+ display.log(chalk.yellow("Configuration not found."));
34
+ display.log(chalk.cyan("Please run 'morpheus init' first to set up your agent."));
35
+ process.exit(1);
36
+ }
37
+ // Write current PID
38
+ await writePid(process.pid);
39
+ const configManager = ConfigManager.getInstance();
40
+ const config = await configManager.load();
41
+ // Initialize persistent logging
42
+ await display.initialize(config.logging);
43
+ display.log(chalk.green(`Morpheus Agent (${config.agent.name}) starting...`));
44
+ display.log(chalk.gray(`PID: ${process.pid}`));
45
+ if (options.ui) {
46
+ display.log(chalk.blue(`Web UI enabled to port ${options.port}`));
47
+ }
48
+ // Initialize Agent
49
+ const agent = new Agent(config);
50
+ try {
51
+ display.startSpinner(`Initializing ${config.llm.provider} agent...`);
52
+ await agent.initialize();
53
+ display.stopSpinner();
54
+ display.log(chalk.green('✓ Agent initialized'), { source: 'Agent' });
55
+ }
56
+ catch (err) {
57
+ display.stopSpinner();
58
+ if (err instanceof ProviderError) {
59
+ display.log(chalk.red(`\nProvider Error (${err.provider}):`));
60
+ display.log(chalk.white(err.message));
61
+ if (err.suggestion) {
62
+ display.log(chalk.yellow(`Tip: ${err.suggestion}`));
63
+ }
64
+ }
65
+ else {
66
+ display.log(chalk.red('\nAgent initialization failed:'));
67
+ display.log(chalk.white(err.message));
68
+ if (err.message.includes('API Key')) {
69
+ display.log(chalk.yellow('Tip: Check your API key in configuration or environment variables.'));
70
+ }
71
+ }
72
+ await clearPid();
73
+ process.exit(1);
74
+ }
75
+ const adapters = [];
76
+ let httpServer;
77
+ // Initialize Web UI
78
+ if (options.ui && config.ui.enabled) {
79
+ try {
80
+ httpServer = new HttpServer();
81
+ // Use CLI port if provided and valid, otherwise fallback to config or default
82
+ const port = parseInt(options.port) || config.ui.port || 3333;
83
+ httpServer.start(port);
84
+ }
85
+ catch (e) {
86
+ display.log(chalk.red(`Failed to start Web UI: ${e.message}`));
87
+ }
88
+ }
89
+ // Initialize Telegram
90
+ if (config.channels.telegram.enabled) {
91
+ if (config.channels.telegram.token) {
92
+ const telegram = new TelegramAdapter(agent);
93
+ try {
94
+ await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
95
+ adapters.push(telegram);
96
+ }
97
+ catch (e) {
98
+ display.log(chalk.red('Failed to initialize Telegram adapter. Continuing...'));
99
+ }
100
+ }
101
+ else {
102
+ display.log(chalk.yellow('Telegram enabled but no token provided. Skipping.'));
103
+ }
104
+ }
105
+ // Handle graceful shutdown
106
+ const shutdown = async (signal) => {
107
+ display.stopSpinner();
108
+ display.log(`\n${signal} received. Shutting down...`);
109
+ if (httpServer) {
110
+ httpServer.stop();
111
+ }
112
+ for (const adapter of adapters) {
113
+ await adapter.disconnect();
114
+ }
115
+ await clearPid();
116
+ process.exit(0);
117
+ };
118
+ process.on('SIGINT', () => shutdown('SIGINT'));
119
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
120
+ // Allow ESC to exit
121
+ if (process.stdin.isTTY) {
122
+ process.stdin.setRawMode(true);
123
+ process.stdin.resume();
124
+ process.stdin.setEncoding('utf8');
125
+ process.stdin.on('data', (key) => {
126
+ // ESC or Ctrl+C
127
+ if (key === '\u001B' || key === '\u0003') {
128
+ shutdown('User Quit');
129
+ }
130
+ });
131
+ }
132
+ // Keep process alive (Mock Agent Loop)
133
+ display.startSpinner('Agent active and listening... (Press ESC to stop)');
134
+ // Prevent node from exiting
135
+ setInterval(() => {
136
+ // Heartbeat or background tasks would go here
137
+ }, 5000);
138
+ }
139
+ catch (error) {
140
+ display.stopSpinner();
141
+ console.error(chalk.red('Failed to start Morpheus:'), error.message);
142
+ await clearPid();
143
+ process.exit(1);
144
+ }
145
+ });
@@ -0,0 +1,21 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { readPid, isProcessRunning, checkStalePid } from '../../runtime/lifecycle.js';
4
+ export const statusCommand = new Command('status')
5
+ .description('Check the status of the Morpheus agent')
6
+ .action(async () => {
7
+ try {
8
+ await checkStalePid();
9
+ const pid = await readPid();
10
+ if (pid && isProcessRunning(pid)) {
11
+ console.log(chalk.green(`Morpheus is running (PID: ${pid})`));
12
+ }
13
+ else {
14
+ console.log(chalk.gray('Morpheus is stopped.'));
15
+ }
16
+ }
17
+ catch (error) {
18
+ console.error(chalk.red('Failed to check status:'), error.message);
19
+ process.exit(1);
20
+ }
21
+ });
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { readPid, isProcessRunning, clearPid, checkStalePid } from '../../runtime/lifecycle.js';
4
+ export const stopCommand = new Command('stop')
5
+ .description('Stop the running Morpheus agent')
6
+ .action(async () => {
7
+ try {
8
+ await checkStalePid();
9
+ const pid = await readPid();
10
+ if (!pid) {
11
+ console.log(chalk.yellow('Morpheus is not running.'));
12
+ return;
13
+ }
14
+ if (!isProcessRunning(pid)) {
15
+ console.log(chalk.yellow('Morpheus is not running (stale PID file cleaned).'));
16
+ await clearPid();
17
+ return;
18
+ }
19
+ process.kill(pid, 'SIGTERM');
20
+ console.log(chalk.green(`Sent stop signal to Morpheus (PID: ${pid}).`));
21
+ // Optional: Wait to ensure it clears the PID file
22
+ // For now, we assume the agent handles its own cleanup via SIGTERM handler
23
+ }
24
+ catch (error) {
25
+ console.error(chalk.red('Failed to stop Morpheus:'), error.message);
26
+ process.exit(1);
27
+ }
28
+ });
@@ -0,0 +1,38 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { startCommand } from './commands/start.js';
6
+ import { stopCommand } from './commands/stop.js';
7
+ import { statusCommand } from './commands/status.js';
8
+ import { configCommand } from './commands/config.js';
9
+ import { doctorCommand } from './commands/doctor.js';
10
+ import { initCommand } from './commands/init.js';
11
+ // Helper to read package.json version
12
+ const getVersion = () => {
13
+ try {
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ // Assuming dist/cli/index.js -> package.json is 2 levels up
17
+ const pkgPath = join(__dirname, '../../package.json');
18
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
19
+ return pkg.version;
20
+ }
21
+ catch (e) {
22
+ return '0.1.0';
23
+ }
24
+ };
25
+ export async function cli() {
26
+ const program = new Command();
27
+ program
28
+ .name('morpheus')
29
+ .description('Morpheus CLI Agent')
30
+ .version(getVersion());
31
+ program.addCommand(initCommand);
32
+ program.addCommand(startCommand);
33
+ program.addCommand(stopCommand);
34
+ program.addCommand(statusCommand);
35
+ program.addCommand(configCommand);
36
+ program.addCommand(doctorCommand);
37
+ program.parse(process.argv);
38
+ }
@@ -0,0 +1,11 @@
1
+ import chalk from 'chalk';
2
+ import figlet from 'figlet';
3
+ export function renderBanner() {
4
+ const art = figlet.textSync('Morpheus', {
5
+ font: 'Standard',
6
+ horizontalLayout: 'default',
7
+ verticalLayout: 'default',
8
+ });
9
+ console.log(chalk.cyanBright(art));
10
+ console.log(chalk.gray(' The Local-First AI Agent specialized in Coding\n'));
11
+ }
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { DEFAULT_CONFIG } from '../../types/config.js';
3
+ describe('ConfigManager', () => {
4
+ it('should have default telegram config properly set', () => {
5
+ const config = DEFAULT_CONFIG;
6
+ expect(config.channels).toBeDefined();
7
+ expect(config.channels.telegram).toBeDefined();
8
+ expect(config.channels.telegram.enabled).toBe(false);
9
+ expect(config.channels.telegram.allowedUsers).toEqual([]);
10
+ });
11
+ });
@@ -0,0 +1,55 @@
1
+ import fs from 'fs-extra';
2
+ import yaml from 'js-yaml';
3
+ import { DEFAULT_CONFIG } from '../types/config.js';
4
+ import { PATHS } from './paths.js';
5
+ import { setByPath } from './utils.js';
6
+ import { ConfigSchema } from './schemas.js';
7
+ export class ConfigManager {
8
+ static instance;
9
+ config = DEFAULT_CONFIG;
10
+ constructor() { }
11
+ static getInstance() {
12
+ if (!ConfigManager.instance) {
13
+ ConfigManager.instance = new ConfigManager();
14
+ }
15
+ return ConfigManager.instance;
16
+ }
17
+ async load() {
18
+ try {
19
+ if (await fs.pathExists(PATHS.config)) {
20
+ const raw = await fs.readFile(PATHS.config, 'utf8');
21
+ const parsed = yaml.load(raw);
22
+ // Validate and merge with defaults via Zod
23
+ this.config = ConfigSchema.parse(parsed);
24
+ }
25
+ else {
26
+ // File doesn't exist, use defaults
27
+ this.config = DEFAULT_CONFIG;
28
+ }
29
+ }
30
+ catch (error) {
31
+ console.error('Failed to load configuration:', error);
32
+ // Fallback to default if load fails
33
+ this.config = DEFAULT_CONFIG;
34
+ }
35
+ return this.config;
36
+ }
37
+ get() {
38
+ return this.config;
39
+ }
40
+ async set(path, value) {
41
+ // Clone current config to apply changes
42
+ const configClone = JSON.parse(JSON.stringify(this.config));
43
+ setByPath(configClone, path, value);
44
+ await this.save(configClone);
45
+ }
46
+ async save(newConfig) {
47
+ // Deep merge or overwrite? simpler to overwrite for now or merge top level
48
+ const updated = { ...this.config, ...newConfig };
49
+ // Validate before saving
50
+ const valid = ConfigSchema.parse(updated);
51
+ await fs.ensureDir(PATHS.root);
52
+ await fs.writeFile(PATHS.config, yaml.dump(valid), 'utf8');
53
+ this.config = valid;
54
+ }
55
+ }
@@ -0,0 +1,15 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ export const USER_HOME = os.homedir();
4
+ export const MORPHEUS_ROOT = path.join(USER_HOME, '.morpheus');
5
+ export const LOGS_DIR = path.join(MORPHEUS_ROOT, 'logs');
6
+ export const PATHS = {
7
+ root: MORPHEUS_ROOT,
8
+ config: path.join(MORPHEUS_ROOT, 'config.yaml'),
9
+ pid: path.join(MORPHEUS_ROOT, 'morpheus.pid'),
10
+ logs: LOGS_DIR,
11
+ memory: path.join(MORPHEUS_ROOT, 'memory'),
12
+ cache: path.join(MORPHEUS_ROOT, 'cache'),
13
+ commands: path.join(MORPHEUS_ROOT, 'commands'),
14
+ mcps: path.join(MORPHEUS_ROOT, 'mcps.json'),
15
+ };
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { DEFAULT_CONFIG } from '../types/config.js';
3
+ // Zod Schema matching MorpheusConfig interface
4
+ export const ConfigSchema = z.object({
5
+ agent: z.object({
6
+ name: z.string().default(DEFAULT_CONFIG.agent.name),
7
+ personality: z.string().default(DEFAULT_CONFIG.agent.personality),
8
+ }).default(DEFAULT_CONFIG.agent),
9
+ llm: z.object({
10
+ provider: z.enum(['openai', 'anthropic', 'ollama', 'gemini']).default(DEFAULT_CONFIG.llm.provider),
11
+ model: z.string().min(1).default(DEFAULT_CONFIG.llm.model),
12
+ temperature: z.number().min(0).max(1).default(DEFAULT_CONFIG.llm.temperature),
13
+ api_key: z.string().optional(),
14
+ }).default(DEFAULT_CONFIG.llm),
15
+ channels: z.object({
16
+ telegram: z.object({
17
+ enabled: z.boolean().default(false),
18
+ token: z.string().optional(),
19
+ allowedUsers: z.array(z.string()).default([]),
20
+ }).default(DEFAULT_CONFIG.channels.telegram),
21
+ discord: z.object({
22
+ enabled: z.boolean().default(false),
23
+ token: z.string().optional(),
24
+ }).default(DEFAULT_CONFIG.channels.discord),
25
+ }).default(DEFAULT_CONFIG.channels),
26
+ ui: z.object({
27
+ enabled: z.boolean().default(DEFAULT_CONFIG.ui.enabled),
28
+ port: z.number().default(DEFAULT_CONFIG.ui.port),
29
+ }).default(DEFAULT_CONFIG.ui),
30
+ logging: z.object({
31
+ enabled: z.boolean().default(DEFAULT_CONFIG.logging.enabled),
32
+ level: z.enum(['debug', 'info', 'warn', 'error']).default(DEFAULT_CONFIG.logging.level),
33
+ retention: z.string().default(DEFAULT_CONFIG.logging.retention),
34
+ }).default(DEFAULT_CONFIG.logging),
35
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Sets a value in a nested object using a dot-notation path.
3
+ *
4
+ * @param obj The object to modify
5
+ * @param path The path string (e.g. "channels.telegram.token")
6
+ * @param value The value to set
7
+ */
8
+ export function setByPath(obj, path, value) {
9
+ const keys = path.split('.');
10
+ let current = obj;
11
+ for (let i = 0; i < keys.length - 1; i++) {
12
+ const key = keys[i];
13
+ // Create nested object if it doesn't exist
14
+ if (current[key] === undefined || current[key] === null) {
15
+ current[key] = {};
16
+ }
17
+ current = current[key];
18
+ }
19
+ const lastKey = keys[keys.length - 1];
20
+ current[lastKey] = value;
21
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import request from 'supertest';
3
+ import express from 'express';
4
+ import bodyParser from 'body-parser';
5
+ import { createApiRouter } from '../api.js';
6
+ import { ConfigManager } from '../../config/manager.js';
7
+ import { DisplayManager } from '../../runtime/display.js';
8
+ // Mock dependencies
9
+ vi.mock('../../config/manager.js');
10
+ vi.mock('../../runtime/display.js');
11
+ vi.mock('fs-extra');
12
+ describe('Config API', () => {
13
+ let app;
14
+ let mockConfigManager;
15
+ let mockDisplayManager;
16
+ beforeEach(() => {
17
+ // Reset mocks
18
+ vi.clearAllMocks();
19
+ // Mock ConfigManager instance
20
+ mockConfigManager = {
21
+ get: vi.fn(),
22
+ save: vi.fn(),
23
+ };
24
+ ConfigManager.getInstance.mockReturnValue(mockConfigManager);
25
+ // Mock DisplayManager instance
26
+ mockDisplayManager = {
27
+ log: vi.fn(),
28
+ };
29
+ DisplayManager.getInstance.mockReturnValue(mockDisplayManager);
30
+ // Setup App
31
+ app = express();
32
+ app.use(bodyParser.json());
33
+ app.use('/api', createApiRouter());
34
+ });
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+ describe('GET /api/config', () => {
39
+ it('should return current configuration', async () => {
40
+ const mockConfig = { agent: { name: 'TestAgent' } };
41
+ mockConfigManager.get.mockReturnValue(mockConfig);
42
+ const res = await request(app).get('/api/config');
43
+ expect(res.status).toBe(200);
44
+ expect(res.body).toEqual(mockConfig);
45
+ expect(mockConfigManager.get).toHaveBeenCalled();
46
+ });
47
+ });
48
+ describe('POST /api/config', () => {
49
+ it('should update configuration and return new config', async () => {
50
+ const oldConfig = { agent: { name: 'OldName' } };
51
+ const newConfig = { agent: { name: 'NewName' } };
52
+ mockConfigManager.get.mockReturnValueOnce(oldConfig).mockReturnValue(newConfig);
53
+ mockConfigManager.save.mockResolvedValue(undefined);
54
+ const res = await request(app)
55
+ .post('/api/config')
56
+ .send({ agent: { name: 'NewName' } });
57
+ expect(res.status).toBe(200);
58
+ expect(res.body).toEqual(newConfig);
59
+ expect(mockConfigManager.save).toHaveBeenCalledWith({ agent: { name: 'NewName' } });
60
+ // Verify logging
61
+ expect(mockDisplayManager.log).toHaveBeenCalled();
62
+ const logCall = mockDisplayManager.log.mock.calls[0][0];
63
+ expect(logCall).toContain('agent.name: "OldName" -> "NewName"');
64
+ });
65
+ it('should handle validation errors', async () => {
66
+ mockConfigManager.get.mockReturnValue({});
67
+ const zodError = new Error('Validation failed');
68
+ zodError.name = 'ZodError';
69
+ zodError.errors = [{ message: 'Invalid field' }];
70
+ mockConfigManager.save.mockRejectedValue(zodError);
71
+ const res = await request(app)
72
+ .post('/api/config')
73
+ .send({ invalid: 'data' });
74
+ expect(res.status).toBe(400);
75
+ expect(res.body.error).toBe('Validation failed');
76
+ expect(res.body.details).toBeDefined();
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,134 @@
1
+ import { Router } from 'express';
2
+ import { ConfigManager } from '../config/manager.js';
3
+ import { PATHS } from '../config/paths.js';
4
+ import { DisplayManager } from '../runtime/display.js';
5
+ import fs from 'fs-extra';
6
+ import path from 'path';
7
+ async function readLastLines(filePath, n) {
8
+ try {
9
+ const content = await fs.readFile(filePath, 'utf8');
10
+ const lines = content.trim().split('\n');
11
+ return lines.slice(-n);
12
+ }
13
+ catch (err) {
14
+ return [];
15
+ }
16
+ }
17
+ export function createApiRouter() {
18
+ const router = Router();
19
+ const configManager = ConfigManager.getInstance();
20
+ router.get('/status', async (req, res) => {
21
+ let version = 'unknown';
22
+ try {
23
+ const pkg = await fs.readJson(path.join(process.cwd(), 'package.json'));
24
+ version = pkg.version;
25
+ }
26
+ catch { }
27
+ const config = configManager.get();
28
+ res.json({
29
+ status: 'online',
30
+ uptimeSeconds: process.uptime(),
31
+ pid: process.pid,
32
+ projectVersion: version,
33
+ nodeVersion: process.version,
34
+ agentName: config.agent.name,
35
+ llmProvider: config.llm.provider,
36
+ llmModel: config.llm.model
37
+ });
38
+ });
39
+ router.get('/config', (req, res) => {
40
+ res.json(configManager.get());
41
+ });
42
+ // Calculate diff between two objects
43
+ const getDiff = (obj1, obj2, prefix = '') => {
44
+ const changes = [];
45
+ const keys = new Set([...Object.keys(obj1 || {}), ...Object.keys(obj2 || {})]);
46
+ for (const key of keys) {
47
+ const val1 = obj1?.[key];
48
+ const val2 = obj2?.[key];
49
+ const currentPath = prefix ? `${prefix}.${key}` : key;
50
+ // Skip if identical
51
+ if (JSON.stringify(val1) === JSON.stringify(val2))
52
+ continue;
53
+ if (typeof val1 === 'object' && val1 !== null &&
54
+ typeof val2 === 'object' && val2 !== null &&
55
+ !Array.isArray(val1) && !Array.isArray(val2)) {
56
+ changes.push(...getDiff(val1, val2, currentPath));
57
+ }
58
+ else {
59
+ // Mask secrets in logs
60
+ const isSecret = currentPath.includes('key') || currentPath.includes('token');
61
+ const v1Display = isSecret ? '***' : JSON.stringify(val1);
62
+ const v2Display = isSecret ? '***' : JSON.stringify(val2);
63
+ changes.push(`${currentPath}: ${v1Display} -> ${v2Display}`);
64
+ }
65
+ }
66
+ return changes;
67
+ };
68
+ router.post('/config', async (req, res) => {
69
+ try {
70
+ const oldConfig = JSON.parse(JSON.stringify(configManager.get()));
71
+ // Save will validate against Zod schema
72
+ await configManager.save(req.body);
73
+ const newConfig = configManager.get();
74
+ const changes = getDiff(oldConfig, newConfig);
75
+ if (changes.length > 0) {
76
+ const display = DisplayManager.getInstance();
77
+ display.log(`Configuration updated via UI:\n - ${changes.join('\n - ')}`, {
78
+ source: 'Config',
79
+ level: 'info'
80
+ });
81
+ }
82
+ res.json(newConfig);
83
+ }
84
+ catch (error) {
85
+ if (error.name === 'ZodError') {
86
+ res.status(400).json({ error: 'Validation failed', details: error.errors });
87
+ }
88
+ else {
89
+ res.status(400).json({ error: error.message });
90
+ }
91
+ }
92
+ });
93
+ // Keep PUT for backward compatibility if needed, or remove.
94
+ // Tasks says Implement POST. I'll remove PUT to avoid confusion or redirect it.
95
+ router.put('/config', async (req, res) => {
96
+ // Redirect to POST logic or just reuse
97
+ res.status(307).redirect(307, '/api/config');
98
+ });
99
+ router.get('/logs', async (req, res) => {
100
+ try {
101
+ await fs.ensureDir(PATHS.logs);
102
+ const files = await fs.readdir(PATHS.logs);
103
+ const logFiles = await Promise.all(files.filter(f => f.endsWith('.log')).map(async (name) => {
104
+ const stats = await fs.stat(path.join(PATHS.logs, name));
105
+ return {
106
+ name,
107
+ size: stats.size,
108
+ modified: stats.mtime.toISOString()
109
+ };
110
+ }));
111
+ logFiles.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
112
+ res.json(logFiles);
113
+ }
114
+ catch (e) {
115
+ res.status(500).json({ error: 'Failed to list logs' });
116
+ }
117
+ });
118
+ router.get('/logs/:filename', async (req, res) => {
119
+ const filename = req.params.filename;
120
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
121
+ return res.status(400).json({ error: 'Invalid filename' });
122
+ }
123
+ // Explicitly cast req.query.limit to string, or handle ParsedQs type
124
+ const limitQuery = req.query.limit;
125
+ const limit = limitQuery ? parseInt(String(limitQuery)) : 50;
126
+ const filePath = path.join(PATHS.logs, filename);
127
+ if (!await fs.pathExists(filePath)) {
128
+ return res.status(404).json({ error: 'Log file not found' });
129
+ }
130
+ const lines = await readLastLines(filePath, limit);
131
+ res.json({ lines: lines.reverse() });
132
+ });
133
+ return router;
134
+ }
@@ -0,0 +1,52 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import bodyParser from 'body-parser';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { ConfigManager } from '../config/manager.js';
7
+ import { DisplayManager } from '../runtime/display.js';
8
+ import { createApiRouter } from './api.js';
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ export class HttpServer {
12
+ app;
13
+ server;
14
+ constructor() {
15
+ this.app = express();
16
+ this.setupMiddleware();
17
+ this.setupRoutes();
18
+ }
19
+ setupMiddleware() {
20
+ this.app.use(cors());
21
+ this.app.use(bodyParser.json());
22
+ }
23
+ setupRoutes() {
24
+ this.app.use('/api', createApiRouter());
25
+ // Serve static frontend from compiled output
26
+ const uiPath = path.resolve(__dirname, '../ui');
27
+ this.app.use(express.static(uiPath));
28
+ // Express 5 requires regex for catch-all instead of '*'
29
+ this.app.get(/.*/, (req, res) => {
30
+ if (req.path.startsWith('/api')) {
31
+ return res.status(404).json({ error: 'Endpoint not found' });
32
+ }
33
+ res.sendFile(path.join(uiPath, 'index.html'));
34
+ });
35
+ }
36
+ start(port = 3333) {
37
+ const config = ConfigManager.getInstance().get();
38
+ if (!config.ui.enabled) {
39
+ return;
40
+ }
41
+ const activePort = config.ui.port || port;
42
+ this.server = this.app.listen(activePort, () => {
43
+ // Intentionally log this to console for user visibility
44
+ DisplayManager.getInstance().log(`Web UI available at http://localhost:${activePort}`, { source: 'Web UI' });
45
+ });
46
+ }
47
+ stop() {
48
+ if (this.server) {
49
+ this.server.close();
50
+ }
51
+ }
52
+ }