mob-coordinator 0.2.1
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 +207 -0
- package/bin/mob.js +20 -0
- package/dist/client/assets/index-BE5nxcXU.css +32 -0
- package/dist/client/assets/index-DkdPImCW.js +11 -0
- package/dist/client/index.html +14 -0
- package/dist/server/server/__tests__/sanitize.test.js +154 -0
- package/dist/server/server/discovery.js +36 -0
- package/dist/server/server/express-app.js +173 -0
- package/dist/server/server/index.js +54 -0
- package/dist/server/server/instance-manager.js +434 -0
- package/dist/server/server/jira-client.js +33 -0
- package/dist/server/server/pty-manager.js +98 -0
- package/dist/server/server/scrollback-buffer.js +102 -0
- package/dist/server/server/session-store.js +127 -0
- package/dist/server/server/settings-manager.js +87 -0
- package/dist/server/server/status-reader.js +29 -0
- package/dist/server/server/terminal-state-detector.js +42 -0
- package/dist/server/server/types.js +1 -0
- package/dist/server/server/util/id.js +4 -0
- package/dist/server/server/util/logger.js +34 -0
- package/dist/server/server/util/platform.js +48 -0
- package/dist/server/server/util/sanitize.js +126 -0
- package/dist/server/server/ws-server.js +197 -0
- package/dist/server/shared/constants.js +8 -0
- package/dist/server/shared/protocol.js +1 -0
- package/dist/server/shared/settings.js +54 -0
- package/package.json +68 -0
- package/scripts/postinstall.cjs +68 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Mob — Claude Instance Dashboard</title>
|
|
7
|
+
<link rel="icon" href="/favicon.png" />
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DkdPImCW.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BE5nxcXU.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { shellQuote, isValidModel, isValidPermissionMode, isValidSessionId, isValidCwd, stripControlChars, validateLaunchPayload, validateHookPayload, } from '../util/sanitize.js';
|
|
3
|
+
describe('shellQuote', () => {
|
|
4
|
+
it('wraps simple string in single quotes', () => {
|
|
5
|
+
expect(shellQuote('hello')).toBe("'hello'");
|
|
6
|
+
});
|
|
7
|
+
it('escapes single quotes', () => {
|
|
8
|
+
expect(shellQuote("it's")).toBe("'it'\\''s'");
|
|
9
|
+
});
|
|
10
|
+
it('handles empty string', () => {
|
|
11
|
+
expect(shellQuote('')).toBe("''");
|
|
12
|
+
});
|
|
13
|
+
it('neutralizes command injection attempts', () => {
|
|
14
|
+
const malicious = '$(rm -rf /)';
|
|
15
|
+
const quoted = shellQuote(malicious);
|
|
16
|
+
expect(quoted).toBe("'$(rm -rf /)'");
|
|
17
|
+
// Inside single quotes, $() is literal — not executed by the shell
|
|
18
|
+
expect(quoted.startsWith("'")).toBe(true);
|
|
19
|
+
expect(quoted.endsWith("'")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('neutralizes backtick injection', () => {
|
|
22
|
+
expect(shellQuote('`whoami`')).toBe("'`whoami`'");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('isValidModel', () => {
|
|
26
|
+
it('accepts valid model names', () => {
|
|
27
|
+
expect(isValidModel('claude-opus-4-6')).toBe(true);
|
|
28
|
+
expect(isValidModel('claude-sonnet-4-6')).toBe(true);
|
|
29
|
+
expect(isValidModel('claude-haiku-4-5-20251001')).toBe(true);
|
|
30
|
+
expect(isValidModel('gpt-4o')).toBe(true);
|
|
31
|
+
expect(isValidModel('model/v1.0')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('rejects injection attempts', () => {
|
|
34
|
+
expect(isValidModel('$(rm -rf /)')).toBe(false);
|
|
35
|
+
expect(isValidModel('model; echo pwned')).toBe(false);
|
|
36
|
+
expect(isValidModel('model`whoami`')).toBe(false);
|
|
37
|
+
expect(isValidModel('')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it('rejects overly long values', () => {
|
|
40
|
+
expect(isValidModel('a'.repeat(101))).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('isValidPermissionMode', () => {
|
|
44
|
+
it('accepts valid modes', () => {
|
|
45
|
+
expect(isValidPermissionMode('default')).toBe(true);
|
|
46
|
+
expect(isValidPermissionMode('plan')).toBe(true);
|
|
47
|
+
expect(isValidPermissionMode('bypassPermissions')).toBe(true);
|
|
48
|
+
expect(isValidPermissionMode('full')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('rejects invalid modes', () => {
|
|
51
|
+
expect(isValidPermissionMode('$(evil)')).toBe(false);
|
|
52
|
+
expect(isValidPermissionMode('admin')).toBe(false);
|
|
53
|
+
expect(isValidPermissionMode('')).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('isValidSessionId', () => {
|
|
57
|
+
it('accepts valid session IDs', () => {
|
|
58
|
+
expect(isValidSessionId('abc-123_def')).toBe(true);
|
|
59
|
+
expect(isValidSessionId('session-id-001')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
it('rejects injection attempts', () => {
|
|
62
|
+
expect(isValidSessionId('$(whoami)')).toBe(false);
|
|
63
|
+
expect(isValidSessionId('id; rm -rf /')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('isValidCwd', () => {
|
|
67
|
+
it('accepts absolute paths', () => {
|
|
68
|
+
expect(isValidCwd('/home/user/project')).toBe(true);
|
|
69
|
+
expect(isValidCwd('~/project')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('rejects relative paths', () => {
|
|
72
|
+
expect(isValidCwd('relative/path')).toBe(false);
|
|
73
|
+
expect(isValidCwd('../escape')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('rejects null bytes', () => {
|
|
76
|
+
expect(isValidCwd('/home/user\0/evil')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it('rejects overly long paths', () => {
|
|
79
|
+
expect(isValidCwd('/' + 'a'.repeat(1024))).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('rejects empty', () => {
|
|
82
|
+
expect(isValidCwd('')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('stripControlChars', () => {
|
|
86
|
+
it('removes control characters', () => {
|
|
87
|
+
expect(stripControlChars('hello\x00world')).toBe('helloworld');
|
|
88
|
+
expect(stripControlChars('test\x1B[31m')).toBe('test[31m');
|
|
89
|
+
});
|
|
90
|
+
it('preserves newlines and tabs', () => {
|
|
91
|
+
expect(stripControlChars('hello\nworld\ttab')).toBe('hello\nworld\ttab');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('validateLaunchPayload', () => {
|
|
95
|
+
it('accepts valid payload', () => {
|
|
96
|
+
const result = validateLaunchPayload({
|
|
97
|
+
cwd: '~/project',
|
|
98
|
+
name: 'my-instance',
|
|
99
|
+
model: 'claude-opus-4-6',
|
|
100
|
+
permissionMode: 'default',
|
|
101
|
+
});
|
|
102
|
+
expect(result.valid).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
it('accepts minimal payload', () => {
|
|
105
|
+
const result = validateLaunchPayload({ cwd: '/home/user' });
|
|
106
|
+
expect(result.valid).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it('rejects missing cwd', () => {
|
|
109
|
+
const result = validateLaunchPayload({ name: 'test' });
|
|
110
|
+
expect(result.valid).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
it('rejects invalid model', () => {
|
|
113
|
+
const result = validateLaunchPayload({ cwd: '/home', model: '$(evil)' });
|
|
114
|
+
expect(result.valid).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
it('rejects invalid permissionMode', () => {
|
|
117
|
+
const result = validateLaunchPayload({ cwd: '/home', permissionMode: 'hacker' });
|
|
118
|
+
expect(result.valid).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
it('rejects non-object payload', () => {
|
|
121
|
+
expect(validateLaunchPayload(null).valid).toBe(false);
|
|
122
|
+
expect(validateLaunchPayload('string').valid).toBe(false);
|
|
123
|
+
expect(validateLaunchPayload(42).valid).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('validateHookPayload', () => {
|
|
127
|
+
it('accepts valid hook data', () => {
|
|
128
|
+
const result = validateHookPayload({
|
|
129
|
+
id: 'mob-abc123',
|
|
130
|
+
cwd: '/home/user',
|
|
131
|
+
state: 'running',
|
|
132
|
+
});
|
|
133
|
+
expect(result.valid).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
it('rejects missing id', () => {
|
|
136
|
+
const result = validateHookPayload({ cwd: '/home', state: 'running' });
|
|
137
|
+
expect(result.valid).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
it('rejects overly long id', () => {
|
|
140
|
+
const result = validateHookPayload({ id: 'a'.repeat(201) });
|
|
141
|
+
expect(result.valid).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
it('rejects null bytes in cwd', () => {
|
|
144
|
+
const result = validateHookPayload({ id: 'test', cwd: '/home\0/evil' });
|
|
145
|
+
expect(result.valid).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
it('strips control chars from name', () => {
|
|
148
|
+
const result = validateHookPayload({ id: 'test', name: 'hello\x00world' });
|
|
149
|
+
expect(result.valid).toBe(true);
|
|
150
|
+
if (result.valid) {
|
|
151
|
+
expect(result.data.name).toBe('helloworld');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
import { readStatusFile } from './status-reader.js';
|
|
5
|
+
import { getInstancesDir } from './util/platform.js';
|
|
6
|
+
import { ensureDir } from './util/platform.js';
|
|
7
|
+
export class DiscoveryService extends EventEmitter {
|
|
8
|
+
watcher = null;
|
|
9
|
+
dir;
|
|
10
|
+
constructor() {
|
|
11
|
+
super();
|
|
12
|
+
this.dir = getInstancesDir();
|
|
13
|
+
}
|
|
14
|
+
start() {
|
|
15
|
+
ensureDir(this.dir);
|
|
16
|
+
this.watcher = chokidar.watch(path.join(this.dir, '*.json'), {
|
|
17
|
+
ignoreInitial: false,
|
|
18
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
19
|
+
});
|
|
20
|
+
this.watcher.on('add', (fp) => this.handleFileChange(fp));
|
|
21
|
+
this.watcher.on('change', (fp) => this.handleFileChange(fp));
|
|
22
|
+
this.watcher.on('unlink', (fp) => {
|
|
23
|
+
const id = path.basename(fp, '.json');
|
|
24
|
+
this.emit('remove', id);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
handleFileChange(filePath) {
|
|
28
|
+
const status = readStatusFile(filePath);
|
|
29
|
+
if (status) {
|
|
30
|
+
this.emit('update', status, filePath);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
stop() {
|
|
34
|
+
this.watcher?.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { isPathWithinHome, validateHookPayload } from './util/sanitize.js';
|
|
8
|
+
import { createLogger } from './util/logger.js';
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const log = createLogger('http');
|
|
11
|
+
export function createApp(instanceManager, settingsManager) {
|
|
12
|
+
const app = express();
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
// Request logging for API routes
|
|
15
|
+
app.use('/api', (req, _res, next) => {
|
|
16
|
+
log.info(`${req.method} ${req.originalUrl}`);
|
|
17
|
+
next();
|
|
18
|
+
});
|
|
19
|
+
// Serve static frontend in production (only if built)
|
|
20
|
+
const clientDir = path.join(__dirname, '..', 'client');
|
|
21
|
+
const indexHtml = path.join(clientDir, 'index.html');
|
|
22
|
+
const hasBuiltClient = fs.existsSync(indexHtml);
|
|
23
|
+
if (hasBuiltClient) {
|
|
24
|
+
app.use(express.static(clientDir));
|
|
25
|
+
}
|
|
26
|
+
// Directory autocomplete for launch dialog (restricted to home directory)
|
|
27
|
+
app.get('/api/completions/dirs', (req, res) => {
|
|
28
|
+
const partial = req.query.q || '';
|
|
29
|
+
if (!partial) {
|
|
30
|
+
res.json([]);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Expand ~ to home dir
|
|
34
|
+
const home = os.homedir();
|
|
35
|
+
const expanded = partial.startsWith('~')
|
|
36
|
+
? path.join(home, partial.slice(1))
|
|
37
|
+
: partial;
|
|
38
|
+
// Restrict to home directory tree
|
|
39
|
+
const resolved = path.resolve(expanded);
|
|
40
|
+
if (!isPathWithinHome(resolved)) {
|
|
41
|
+
res.json([]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const dir = expanded.endsWith('/') ? expanded : path.dirname(expanded);
|
|
45
|
+
const prefix = expanded.endsWith('/') ? '' : path.basename(expanded);
|
|
46
|
+
// Verify dir is also within home
|
|
47
|
+
const resolvedDir = path.resolve(dir);
|
|
48
|
+
if (!isPathWithinHome(resolvedDir)) {
|
|
49
|
+
res.json([]);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
|
|
54
|
+
const matches = entries
|
|
55
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
56
|
+
.slice(0, 20)
|
|
57
|
+
.map((e) => {
|
|
58
|
+
const full = path.join(resolvedDir, e.name);
|
|
59
|
+
const display = full.startsWith(home) ? '~' + full.slice(home.length) : full;
|
|
60
|
+
return { path: full, display: display + '/' };
|
|
61
|
+
});
|
|
62
|
+
res.json(matches);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
res.json([]);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// Browse for directory — opens native folder picker
|
|
69
|
+
app.post('/api/browse-dir', (req, res) => {
|
|
70
|
+
const startDir = req.body?.startDir || process.env.HOME || 'C:\\';
|
|
71
|
+
const expanded = startDir.startsWith('~')
|
|
72
|
+
? path.join(process.env.HOME || 'C:\\', startDir.slice(1))
|
|
73
|
+
: startDir;
|
|
74
|
+
// Validate path has no null bytes or newlines
|
|
75
|
+
if (expanded.includes('\0') || expanded.includes('\n') || expanded.includes('\r')) {
|
|
76
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
log.info('browse-dir requested, startDir:', expanded);
|
|
80
|
+
if (process.platform === 'win32') {
|
|
81
|
+
const psPath = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
82
|
+
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'browse-dir.ps1');
|
|
83
|
+
execFile(psPath, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-StartPath', expanded], { timeout: 60000, windowsHide: false }, (err, stdout, stderr) => {
|
|
84
|
+
if (err) {
|
|
85
|
+
log.error('browse-dir error:', err.message, stderr);
|
|
86
|
+
res.json({ cancelled: true });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const selected = stdout.trim();
|
|
90
|
+
log.info('browse-dir result:', selected || '(cancelled)');
|
|
91
|
+
if (selected) {
|
|
92
|
+
res.json({ path: selected });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
res.json({ cancelled: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
else if (process.platform === 'darwin') {
|
|
100
|
+
// Shell-quote the path within the AppleScript string to prevent injection
|
|
101
|
+
const escapedPath = expanded.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
102
|
+
execFile('osascript', ['-e', `choose folder with prompt "Select working directory" default location POSIX file "${escapedPath}"`], { timeout: 60000 }, (err, stdout) => {
|
|
103
|
+
if (err) {
|
|
104
|
+
res.json({ cancelled: true });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const alias = stdout.trim();
|
|
108
|
+
const posix = alias.replace(/^alias [^:]+:/, '/').replace(/:/g, '/');
|
|
109
|
+
res.json({ path: posix });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
execFile('zenity', ['--file-selection', '--directory', `--filename=${expanded}/`], { timeout: 60000 }, (err, stdout) => {
|
|
114
|
+
if (err) {
|
|
115
|
+
res.json({ cancelled: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
res.json({ path: stdout.trim() });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
// Platform info for client feature detection
|
|
123
|
+
app.get('/api/platform', (_req, res) => {
|
|
124
|
+
res.json({ platform: process.platform });
|
|
125
|
+
});
|
|
126
|
+
// Settings API
|
|
127
|
+
app.get('/api/settings', (_req, res) => {
|
|
128
|
+
res.json(settingsManager.getRedacted());
|
|
129
|
+
});
|
|
130
|
+
app.put('/api/settings', (req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
settingsManager.update(req.body);
|
|
133
|
+
instanceManager.refreshTicketFields();
|
|
134
|
+
res.json(settingsManager.getRedacted());
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
res.status(400).json({ error: err.message || 'Invalid settings' });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
// Hook endpoint — receives status updates from hook scripts
|
|
141
|
+
app.post('/api/hook', (req, res) => {
|
|
142
|
+
const result = validateHookPayload(req.body);
|
|
143
|
+
if (!result.valid) {
|
|
144
|
+
res.status(400).json({ error: result.error });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const data = result.data;
|
|
148
|
+
log.info(`hook update: id=${data.id} state=${data.state} topic=${data.topic || '(none)'}`);
|
|
149
|
+
data.lastUpdated = data.lastUpdated || Date.now();
|
|
150
|
+
instanceManager.handleHookUpdate(data);
|
|
151
|
+
res.json({ ok: true });
|
|
152
|
+
});
|
|
153
|
+
// REST: list instances
|
|
154
|
+
app.get('/api/instances', (_req, res) => {
|
|
155
|
+
res.json(instanceManager.getAll());
|
|
156
|
+
});
|
|
157
|
+
// REST: get single instance
|
|
158
|
+
app.get('/api/instances/:id', (req, res) => {
|
|
159
|
+
const info = instanceManager.get(req.params.id);
|
|
160
|
+
if (!info) {
|
|
161
|
+
res.status(404).json({ error: 'Not found' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.json(info);
|
|
165
|
+
});
|
|
166
|
+
// Fallback to index.html for SPA routing (production only)
|
|
167
|
+
if (hasBuiltClient) {
|
|
168
|
+
app.get('*', (_req, res) => {
|
|
169
|
+
res.sendFile(indexHtml);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return app;
|
|
173
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { createApp } from './express-app.js';
|
|
3
|
+
import { createWsServer } from './ws-server.js';
|
|
4
|
+
import { InstanceManager } from './instance-manager.js';
|
|
5
|
+
import { PtyManager } from './pty-manager.js';
|
|
6
|
+
import { DiscoveryService } from './discovery.js';
|
|
7
|
+
import { SessionStore } from './session-store.js';
|
|
8
|
+
import { ScrollbackBuffer } from './scrollback-buffer.js';
|
|
9
|
+
import { SettingsManager } from './settings-manager.js';
|
|
10
|
+
import { ensureDir, getMobDir, getInstancesDir, getSessionsDir, getScrollbackDir } from './util/platform.js';
|
|
11
|
+
import { DEFAULT_PORT } from '../shared/constants.js';
|
|
12
|
+
const port = parseInt(process.env.MOB_PORT || '', 10) || DEFAULT_PORT;
|
|
13
|
+
const host = process.env.MOB_HOST || '127.0.0.1';
|
|
14
|
+
// Ensure directories exist
|
|
15
|
+
ensureDir(getMobDir());
|
|
16
|
+
ensureDir(getInstancesDir());
|
|
17
|
+
ensureDir(getSessionsDir());
|
|
18
|
+
ensureDir(getScrollbackDir());
|
|
19
|
+
const settingsManager = new SettingsManager();
|
|
20
|
+
const ptyManager = new PtyManager();
|
|
21
|
+
const discovery = new DiscoveryService();
|
|
22
|
+
const sessionStore = new SessionStore();
|
|
23
|
+
const scrollbackBuffer = new ScrollbackBuffer();
|
|
24
|
+
scrollbackBuffer.start();
|
|
25
|
+
sessionStore.pruneExpired();
|
|
26
|
+
const instanceManager = new InstanceManager(ptyManager, discovery, sessionStore, scrollbackBuffer, settingsManager);
|
|
27
|
+
const app = createApp(instanceManager, settingsManager);
|
|
28
|
+
const server = http.createServer(app);
|
|
29
|
+
createWsServer(server, instanceManager, ptyManager);
|
|
30
|
+
discovery.start();
|
|
31
|
+
instanceManager.startStaleCheck();
|
|
32
|
+
server.listen(port, host, () => {
|
|
33
|
+
console.log(`Mob dashboard running at http://${host}:${port}`);
|
|
34
|
+
console.log(`WebSocket endpoint: ws://${host}:${port}/mob-ws`);
|
|
35
|
+
});
|
|
36
|
+
// Graceful shutdown
|
|
37
|
+
function shutdown() {
|
|
38
|
+
console.log('\nShutting down...');
|
|
39
|
+
instanceManager.saveAllAsStopped();
|
|
40
|
+
// Kill all PTY processes
|
|
41
|
+
for (const [id] of ptyManager.getAll()) {
|
|
42
|
+
console.log(`Killing PTY: ${id}`);
|
|
43
|
+
ptyManager.kill(id);
|
|
44
|
+
}
|
|
45
|
+
scrollbackBuffer.stop();
|
|
46
|
+
instanceManager.stop();
|
|
47
|
+
// Brief grace period for clean exit
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
server.close();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}, 500);
|
|
52
|
+
}
|
|
53
|
+
process.on('SIGINT', shutdown);
|
|
54
|
+
process.on('SIGTERM', shutdown);
|