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.
- package/README.md +129 -0
- package/bin/morpheus.js +19 -0
- package/dist/channels/__tests__/telegram.test.js +63 -0
- package/dist/channels/telegram.js +89 -0
- package/dist/cli/commands/config.js +81 -0
- package/dist/cli/commands/doctor.js +69 -0
- package/dist/cli/commands/init.js +108 -0
- package/dist/cli/commands/start.js +145 -0
- package/dist/cli/commands/status.js +21 -0
- package/dist/cli/commands/stop.js +28 -0
- package/dist/cli/index.js +38 -0
- package/dist/cli/utils/render.js +11 -0
- package/dist/config/__tests__/manager.test.js +11 -0
- package/dist/config/manager.js +55 -0
- package/dist/config/paths.js +15 -0
- package/dist/config/schemas.js +35 -0
- package/dist/config/utils.js +21 -0
- package/dist/http/__tests__/config_api.test.js +79 -0
- package/dist/http/api.js +134 -0
- package/dist/http/server.js +52 -0
- package/dist/runtime/__tests__/agent.test.js +91 -0
- package/dist/runtime/__tests__/agent_persistence.test.js +148 -0
- package/dist/runtime/__tests__/display.test.js +135 -0
- package/dist/runtime/__tests__/manual_start_verify.js +42 -0
- package/dist/runtime/__tests__/manual_us1.js +33 -0
- package/dist/runtime/agent.js +84 -0
- package/dist/runtime/display.js +146 -0
- package/dist/runtime/errors.js +16 -0
- package/dist/runtime/lifecycle.js +41 -0
- package/dist/runtime/memory/__tests__/sqlite.test.js +179 -0
- package/dist/runtime/memory/sqlite.js +192 -0
- package/dist/runtime/providers/factory.js +62 -0
- package/dist/runtime/scaffold.js +31 -0
- package/dist/runtime/types.js +1 -0
- package/dist/types/config.js +24 -0
- package/dist/types/display.js +1 -0
- package/dist/ui/assets/index-nNle8n-Z.css +1 -0
- package/dist/ui/assets/index-ySbKLOXZ.js +50 -0
- package/dist/ui/index.html +14 -0
- package/dist/ui/vite.svg +31 -0
- 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
|
+
});
|
package/dist/http/api.js
ADDED
|
@@ -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
|
+
}
|