thinknagent 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/bin/thinknagent.js +123 -0
- package/install/setup.sh +79 -0
- package/install/thinknagent.service +38 -0
- package/lib/agent.js +118 -0
- package/lib/alerts.js +123 -0
- package/lib/connect.js +130 -0
- package/lib/logwatcher.js +108 -0
- package/lib/metrics.js +102 -0
- package/lib/shell.js +104 -0
- package/lib/store.js +46 -0
- package/package.json +29 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { Command } = require('commander');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const ora = require('ora');
|
|
7
|
+
const { v4: uuid} = require('uuid');
|
|
8
|
+
const store = require('../lib/store');
|
|
9
|
+
const Agent = require('../lib/agent');
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('thinknagent')
|
|
15
|
+
.description('ThinkNCollab server agent')
|
|
16
|
+
.version(require('../package.json').version);
|
|
17
|
+
|
|
18
|
+
// ── init ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
program
|
|
20
|
+
.command('init')
|
|
21
|
+
.description('Register this server with ThinkNCollab')
|
|
22
|
+
.requiredOption('--server <url>', 'ThinkNCollab server URL (e.g. https://thinkncollab.com)')
|
|
23
|
+
.requiredOption('--name <name>', 'Display name for this server on the DevOps Wall')
|
|
24
|
+
.option('--gpu', 'Enable GPU metrics (requires nvidia-smi)')
|
|
25
|
+
.option('--logs <paths>', 'Comma-separated log file paths to stream')
|
|
26
|
+
.action(async (opts) => {
|
|
27
|
+
const existing = store.get('agentToken');
|
|
28
|
+
if (existing) {
|
|
29
|
+
console.log(chalk.yellow('Already registered. Run `thinknagent status` to check.'));
|
|
30
|
+
console.log(chalk.gray('To re-register: thinknagent revoke && thinknagent init ...'));
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const agentId = uuid();
|
|
35
|
+
const cfg = {
|
|
36
|
+
agentId,
|
|
37
|
+
serverUrl: opts.server.replace(/\/$/, ''),
|
|
38
|
+
name: opts.name,
|
|
39
|
+
gpu: !!opts.gpu,
|
|
40
|
+
logs: opts.logs ? opts.logs.split(',').map(s => s.trim()) : [],
|
|
41
|
+
alerts: [
|
|
42
|
+
// sensible defaults — Owner can edit in browser
|
|
43
|
+
{ id: 'cpu-high', metric: 'cpu.usage', op: 'gt', value: 85, for: 60, severity: 'warning' },
|
|
44
|
+
{ id: 'cpu-crit', metric: 'cpu.usage', op: 'gt', value: 95, for: 30, severity: 'critical' },
|
|
45
|
+
{ id: 'mem-high', metric: 'memory.usedPct', op: 'gt', value: 85, for: 60, severity: 'warning' },
|
|
46
|
+
{ id: 'disk-root', metric: 'disk./', op: 'gt', value: 90, for: 0, severity: 'critical' },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
store.write(cfg);
|
|
51
|
+
|
|
52
|
+
console.log(chalk.cyan('\n thinknagent') + chalk.gray(` v${require('../package.json').version}`));
|
|
53
|
+
console.log(chalk.gray(' ─────────────────────────────────────────'));
|
|
54
|
+
console.log(` Server : ${chalk.white(cfg.serverUrl)}`);
|
|
55
|
+
console.log(` Name : ${chalk.white(cfg.name)}`);
|
|
56
|
+
console.log(` Agent ID: ${chalk.white(agentId)}`);
|
|
57
|
+
console.log(` GPU : ${cfg.gpu ? chalk.green('enabled') : chalk.gray('disabled')}`);
|
|
58
|
+
console.log(` Logs : ${cfg.logs.length ? chalk.white(cfg.logs.join(', ')) : chalk.gray('none')}`);
|
|
59
|
+
console.log(chalk.gray(' ─────────────────────────────────────────'));
|
|
60
|
+
console.log(chalk.yellow('\n Next step:'));
|
|
61
|
+
console.log(' 1. Run: ' + chalk.cyan('thinknagent start'));
|
|
62
|
+
console.log(' 2. An Owner of the target room must approve this agent in ThinkNCollab');
|
|
63
|
+
console.log(' 3. Once approved, agent becomes active automatically\n');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── start ─────────────────────────────────────────────────────────────────────
|
|
67
|
+
program
|
|
68
|
+
.command('start')
|
|
69
|
+
.description('Start the agent (connect to ThinkNCollab)')
|
|
70
|
+
.option('--dev', 'Dev mode — verbose logging')
|
|
71
|
+
.action((opts) => {
|
|
72
|
+
if (opts.dev) process.env.THINKNAGENT_DEV = '1';
|
|
73
|
+
|
|
74
|
+
const cfg = store.read();
|
|
75
|
+
if (!cfg.serverUrl) {
|
|
76
|
+
console.error(chalk.red('Not initialized. Run: thinknagent init --server <url> --name <name>'));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(chalk.cyan(`\n Starting thinknagent — ${cfg.name || cfg.agentId}`));
|
|
81
|
+
if (!cfg.agentToken) {
|
|
82
|
+
console.log(chalk.yellow(' Status: PENDING — waiting for Owner approval\n'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const agent = new Agent();
|
|
86
|
+
agent.start();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── status ────────────────────────────────────────────────────────────────────
|
|
90
|
+
program
|
|
91
|
+
.command('status')
|
|
92
|
+
.description('Show current agent config and registration status')
|
|
93
|
+
.action(() => {
|
|
94
|
+
const cfg = store.read();
|
|
95
|
+
if (!cfg.serverUrl) {
|
|
96
|
+
console.log(chalk.gray('Not initialized.'));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(chalk.cyan('\n thinknagent status'));
|
|
101
|
+
console.log(chalk.gray(' ────────────────────────────────'));
|
|
102
|
+
console.log(` Name : ${chalk.white(cfg.name || '—')}`);
|
|
103
|
+
console.log(` Server : ${chalk.white(cfg.serverUrl || '—')}`);
|
|
104
|
+
console.log(` Agent ID: ${chalk.white(cfg.agentId || '—')}`);
|
|
105
|
+
console.log(` Role : ${chalk.white(cfg.role || '—')}`);
|
|
106
|
+
console.log(` Room : ${chalk.white(cfg.roomId || '—')}`);
|
|
107
|
+
console.log(` Status : ${cfg.agentToken ? chalk.green('APPROVED') : chalk.yellow('PENDING')}`);
|
|
108
|
+
console.log(` GPU : ${cfg.gpu ? chalk.green('enabled') : chalk.gray('disabled')}`);
|
|
109
|
+
console.log(` Logs : ${(cfg.logs||[]).length ? cfg.logs.join(', ') : chalk.gray('none')}`);
|
|
110
|
+
console.log(` Alerts : ${(cfg.alerts||[]).length} rule(s)`);
|
|
111
|
+
console.log(chalk.gray(' ────────────────────────────────\n'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── revoke ────────────────────────────────────────────────────────────────────
|
|
115
|
+
program
|
|
116
|
+
.command('revoke')
|
|
117
|
+
.description('Remove all credentials from this server')
|
|
118
|
+
.action(() => {
|
|
119
|
+
store.clear();
|
|
120
|
+
console.log(chalk.yellow(' Credentials cleared. Run `thinknagent init` to re-register.'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
program.parse(process.argv);
|
package/install/setup.sh
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ThinkNCollab Agent — installer
|
|
3
|
+
# Usage: curl -fsSL https://thinkncollab.com/install-agent.sh | bash -s -- --server https://thinkncollab.com --name my-server
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
SERVER=""
|
|
8
|
+
NAME=""
|
|
9
|
+
GPU=false
|
|
10
|
+
LOGS=""
|
|
11
|
+
SYSTEMD=false
|
|
12
|
+
|
|
13
|
+
# Parse args
|
|
14
|
+
while [[ $# -gt 0 ]]; do
|
|
15
|
+
case $1 in
|
|
16
|
+
--server) SERVER="$2"; shift 2 ;;
|
|
17
|
+
--name) NAME="$2"; shift 2 ;;
|
|
18
|
+
--gpu) GPU=true; shift ;;
|
|
19
|
+
--logs) LOGS="$2"; shift 2 ;;
|
|
20
|
+
--systemd) SYSTEMD=true; shift ;;
|
|
21
|
+
*) echo "Unknown option: $1"; exit 1 ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
[[ -z "$SERVER" ]] && echo "Error: --server required" && exit 1
|
|
26
|
+
[[ -z "$NAME" ]] && echo "Error: --name required" && exit 1
|
|
27
|
+
|
|
28
|
+
echo ""
|
|
29
|
+
echo " ThinkNCollab Agent Installer"
|
|
30
|
+
echo " ──────────────────────────────"
|
|
31
|
+
|
|
32
|
+
# Check Node.js >= 18
|
|
33
|
+
NODE_VER=$(node --version 2>/dev/null | cut -d. -f1 | tr -d 'v' || echo "0")
|
|
34
|
+
if [[ "$NODE_VER" -lt 18 ]]; then
|
|
35
|
+
echo " Error: Node.js 18+ required (found: $(node --version 2>/dev/null || echo 'not found'))"
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
echo " Node.js : $(node --version) ✓"
|
|
39
|
+
|
|
40
|
+
# Install package
|
|
41
|
+
echo " Installing thinknagent..."
|
|
42
|
+
npm install -g thinknagent --silent
|
|
43
|
+
|
|
44
|
+
# Init
|
|
45
|
+
GPU_FLAG=""
|
|
46
|
+
$GPU && GPU_FLAG="--gpu"
|
|
47
|
+
LOGS_FLAG=""
|
|
48
|
+
[[ -n "$LOGS" ]] && LOGS_FLAG="--logs $LOGS"
|
|
49
|
+
|
|
50
|
+
thinknagent init --server "$SERVER" --name "$NAME" $GPU_FLAG $LOGS_FLAG
|
|
51
|
+
|
|
52
|
+
# Optional systemd setup
|
|
53
|
+
if $SYSTEMD; then
|
|
54
|
+
echo ""
|
|
55
|
+
echo " Setting up systemd service..."
|
|
56
|
+
|
|
57
|
+
# Create system user
|
|
58
|
+
id thinknagent &>/dev/null || useradd --system --no-create-home thinknagent
|
|
59
|
+
|
|
60
|
+
# Copy service file
|
|
61
|
+
AGENT_SERVICE_SRC="$(npm root -g)/thinknagent/install/thinknagent.service"
|
|
62
|
+
cp "$AGENT_SERVICE_SRC" /etc/systemd/system/thinknagent.service
|
|
63
|
+
|
|
64
|
+
systemctl daemon-reload
|
|
65
|
+
systemctl enable thinknagent
|
|
66
|
+
systemctl start thinknagent
|
|
67
|
+
|
|
68
|
+
echo " systemd service: enabled + started ✓"
|
|
69
|
+
echo " Logs: journalctl -u thinknagent -f"
|
|
70
|
+
else
|
|
71
|
+
echo ""
|
|
72
|
+
echo " To start now : thinknagent start"
|
|
73
|
+
echo " To run on boot : Re-run this script with --systemd flag"
|
|
74
|
+
echo " Or with pm2 : pm2 start \$(which thinknagent) -- start && pm2 save"
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
echo ""
|
|
78
|
+
echo " ✓ Done. Open ThinkNCollab and approve this agent in your room's DevOps Wall."
|
|
79
|
+
echo ""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=ThinkNCollab Agent
|
|
3
|
+
Documentation=https://thinkncollab.com/docs/agent
|
|
4
|
+
After=network-online.target
|
|
5
|
+
Wants=network-online.target
|
|
6
|
+
|
|
7
|
+
[Service]
|
|
8
|
+
# Run as a dedicated low-privilege user for security
|
|
9
|
+
# Create with: sudo useradd --system --no-create-home thinknagent
|
|
10
|
+
User=thinknagent
|
|
11
|
+
Group=thinknagent
|
|
12
|
+
|
|
13
|
+
# Point to wherever thinknagent was installed globally
|
|
14
|
+
ExecStart=/usr/bin/node /usr/local/bin/thinknagent start
|
|
15
|
+
|
|
16
|
+
# Restart policy
|
|
17
|
+
Restart=on-failure
|
|
18
|
+
RestartSec=10
|
|
19
|
+
StartLimitInterval=60
|
|
20
|
+
StartLimitBurst=3
|
|
21
|
+
|
|
22
|
+
# Hardening — limit what the process can do
|
|
23
|
+
NoNewPrivileges=true
|
|
24
|
+
ProtectSystem=strict
|
|
25
|
+
ProtectHome=read-only # needed to read ~/.thinknagent/config.json
|
|
26
|
+
ReadWritePaths=/root/.thinknagent /home/%u/.thinknagent
|
|
27
|
+
PrivateTmp=true
|
|
28
|
+
|
|
29
|
+
# Environment
|
|
30
|
+
Environment=NODE_ENV=production
|
|
31
|
+
|
|
32
|
+
# Logging — journald will capture stdout/stderr
|
|
33
|
+
StandardOutput=journal
|
|
34
|
+
StandardError=journal
|
|
35
|
+
SyslogIdentifier=thinknagent
|
|
36
|
+
|
|
37
|
+
[Install]
|
|
38
|
+
WantedBy=multi-user.target
|
package/lib/agent.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Connection = require('./connect');
|
|
4
|
+
const MetricsPoller = require('./metrics');
|
|
5
|
+
const LogWatcher = require('./logwatcher');
|
|
6
|
+
const AlertEngine = require('./alerts');
|
|
7
|
+
const ShellBridge = require('./shell');
|
|
8
|
+
const store = require('./store');
|
|
9
|
+
|
|
10
|
+
class Agent {
|
|
11
|
+
constructor() {
|
|
12
|
+
const cfg = store.read();
|
|
13
|
+
|
|
14
|
+
this.conn = new Connection({
|
|
15
|
+
serverUrl: cfg.serverUrl,
|
|
16
|
+
onReady: ({ role, roomId }) => this._onReady(role, roomId),
|
|
17
|
+
onDisconnect: ({ reason }) => this._onDisconnect(reason),
|
|
18
|
+
onRoleUpdate: ({ role }) => this._onRoleUpdate(role),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
this.metrics = new MetricsPoller({
|
|
22
|
+
connection: this.conn,
|
|
23
|
+
gpu: cfg.gpu || false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
this.logs = new LogWatcher({
|
|
27
|
+
connection: this.conn,
|
|
28
|
+
logPaths: cfg.logs || [],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.alerts = new AlertEngine({
|
|
32
|
+
connection: this.conn,
|
|
33
|
+
rules: cfg.alerts || [],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.shell = new ShellBridge({ connection: this.conn });
|
|
37
|
+
|
|
38
|
+
this._active = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
start() {
|
|
42
|
+
const cfg = store.read();
|
|
43
|
+
if (!cfg.serverUrl) {
|
|
44
|
+
console.error('[agent] No serverUrl configured. Run: thinknagent init');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(`[agent] Connecting to ${cfg.serverUrl}...`);
|
|
49
|
+
this.conn.connect();
|
|
50
|
+
|
|
51
|
+
// Wire metrics → alert engine
|
|
52
|
+
this.conn.socket.on('agent:metrics_internal', (m) => this.alerts.evaluate(m));
|
|
53
|
+
|
|
54
|
+
// Allow server to push updated alert rules at runtime (Owner changed thresholds)
|
|
55
|
+
this.conn.socket.on('agent:rules_updated', ({ rules }) => {
|
|
56
|
+
store.set('alerts', rules);
|
|
57
|
+
this.alerts.reloadRules(rules);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Allow server to push updated log paths at runtime
|
|
61
|
+
this.conn.socket.on('agent:logs_updated', ({ logs }) => {
|
|
62
|
+
store.set('logs', logs);
|
|
63
|
+
this.logs.stop();
|
|
64
|
+
this.logs = new LogWatcher({ connection: this.conn, logPaths: logs });
|
|
65
|
+
this.logs.start();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Graceful shutdown
|
|
69
|
+
process.on('SIGTERM', () => this._shutdown('SIGTERM'));
|
|
70
|
+
process.on('SIGINT', () => this._shutdown('SIGINT'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_onReady(role, roomId) {
|
|
74
|
+
if (this._active) return; // reconnect — already running
|
|
75
|
+
this._active = true;
|
|
76
|
+
console.log(`[agent] Active. Role: ${role} | Room: ${roomId}`);
|
|
77
|
+
|
|
78
|
+
this.metrics.start();
|
|
79
|
+
this.logs.start();
|
|
80
|
+
this.shell.start();
|
|
81
|
+
|
|
82
|
+
// Patch metrics poller to also feed alert engine
|
|
83
|
+
const origEmit = this.conn.emit.bind(this.conn);
|
|
84
|
+
this.conn.emit = (event, data) => {
|
|
85
|
+
if (event === 'agent:metrics') this.alerts.evaluate(data);
|
|
86
|
+
origEmit(event, data);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.conn.emit('agent:ready', {
|
|
90
|
+
hostname: require('os').hostname(),
|
|
91
|
+
platform: process.platform,
|
|
92
|
+
nodeVersion: process.version,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_onDisconnect(reason) {
|
|
97
|
+
this.shell.killAll();
|
|
98
|
+
// metrics + logs keep their intervals; socket.io will reconnect automatically
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_onRoleUpdate({ role }) {
|
|
102
|
+
// If role was downgraded to 'monitor', kill open shell sessions
|
|
103
|
+
if (!this.conn.hasRole('shell')) {
|
|
104
|
+
this.shell.killAll();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_shutdown(signal) {
|
|
109
|
+
console.log(`[agent] ${signal} received — shutting down`);
|
|
110
|
+
this.metrics.stop();
|
|
111
|
+
this.logs.stop();
|
|
112
|
+
this.shell.killAll();
|
|
113
|
+
this.conn.socket?.disconnect();
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = Agent;
|
package/lib/alerts.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Alert engine — evaluates threshold rules against live metrics.
|
|
5
|
+
*
|
|
6
|
+
* Rule format (stored in ~/.thinknagent/config.json under "alerts"):
|
|
7
|
+
* [
|
|
8
|
+
* { id: "cpu-high", metric: "cpu.usage", op: "gt", value: 80, for: 60, severity: "warning" },
|
|
9
|
+
* { id: "cpu-crit", metric: "cpu.usage", op: "gt", value: 95, for: 30, severity: "critical" },
|
|
10
|
+
* { id: "mem-high", metric: "memory.usedPct", op: "gt", value: 85, for: 60, severity: "warning" },
|
|
11
|
+
* { id: "disk-root", metric: "disk./", op: "gt", value: 90, for: 0, severity: "critical" },
|
|
12
|
+
* { id: "proc-down", metric: "process.nginx", op: "eq", value: 0, for: 0, severity: "critical" },
|
|
13
|
+
* ]
|
|
14
|
+
*
|
|
15
|
+
* "for" = seconds the condition must persist before firing (0 = fire immediately)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class AlertEngine {
|
|
19
|
+
constructor({ connection, rules = [] }) {
|
|
20
|
+
this.conn = connection;
|
|
21
|
+
this.rules = rules;
|
|
22
|
+
|
|
23
|
+
// per-rule state: { firstTriggeredAt, fired }
|
|
24
|
+
this._state = {};
|
|
25
|
+
for (const r of rules) {
|
|
26
|
+
this._state[r.id] = { firstTriggeredAt: null, fired: false };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Called by metrics poller on every tick
|
|
31
|
+
evaluate(metrics) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
|
|
34
|
+
for (const rule of this.rules) {
|
|
35
|
+
const val = this._extract(metrics, rule.metric);
|
|
36
|
+
if (val === null) continue;
|
|
37
|
+
|
|
38
|
+
const breached = this._check(val, rule.op, rule.value);
|
|
39
|
+
const state = this._state[rule.id];
|
|
40
|
+
|
|
41
|
+
if (breached) {
|
|
42
|
+
if (!state.firstTriggeredAt) state.firstTriggeredAt = now;
|
|
43
|
+
|
|
44
|
+
const elapsed = (now - state.firstTriggeredAt) / 1000; // seconds
|
|
45
|
+
if (elapsed >= rule.for && !state.fired) {
|
|
46
|
+
state.fired = true;
|
|
47
|
+
this._fire(rule, val);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// Condition cleared
|
|
51
|
+
if (state.fired) {
|
|
52
|
+
this._resolve(rule);
|
|
53
|
+
}
|
|
54
|
+
state.firstTriggeredAt = null;
|
|
55
|
+
state.fired = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_fire(rule, currentValue) {
|
|
61
|
+
console.warn(`[alerts] FIRING: ${rule.id} — ${rule.metric} ${rule.op} ${rule.value} (current: ${currentValue})`);
|
|
62
|
+
this.conn.emit('agent:alert', {
|
|
63
|
+
id: rule.id,
|
|
64
|
+
metric: rule.metric,
|
|
65
|
+
op: rule.op,
|
|
66
|
+
threshold: rule.value,
|
|
67
|
+
current: currentValue,
|
|
68
|
+
severity: rule.severity || 'warning',
|
|
69
|
+
status: 'active',
|
|
70
|
+
firedAt: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_resolve(rule) {
|
|
75
|
+
console.log(`[alerts] RESOLVED: ${rule.id}`);
|
|
76
|
+
this.conn.emit('agent:alert:resolved', {
|
|
77
|
+
id: rule.id,
|
|
78
|
+
resolvedAt: Date.now(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Extract a value from the metrics payload by dot-path
|
|
83
|
+
// Special case: "disk./" = disk entry with mount "/"
|
|
84
|
+
// Special case: "process.nginx" = check if process named "nginx" is running
|
|
85
|
+
_extract(metrics, path) {
|
|
86
|
+
if (path.startsWith('disk.')) {
|
|
87
|
+
const mount = path.slice(5); // e.g. "/"
|
|
88
|
+
const entry = (metrics.disk || []).find(d => d.mount === mount);
|
|
89
|
+
return entry?.usedPct ?? null;
|
|
90
|
+
}
|
|
91
|
+
if (path.startsWith('process.')) {
|
|
92
|
+
const procName = path.slice(8);
|
|
93
|
+
const found = (metrics.processes?.top || []).some(p => p.name.includes(procName));
|
|
94
|
+
return found ? 1 : 0;
|
|
95
|
+
}
|
|
96
|
+
// Standard dot-path: "cpu.usage", "memory.usedPct"
|
|
97
|
+
return path.split('.').reduce((obj, k) => obj?.[k] ?? null, metrics);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_check(val, op, threshold) {
|
|
101
|
+
switch (op) {
|
|
102
|
+
case 'gt': return val > threshold;
|
|
103
|
+
case 'gte': return val >= threshold;
|
|
104
|
+
case 'lt': return val < threshold;
|
|
105
|
+
case 'lte': return val <= threshold;
|
|
106
|
+
case 'eq': return val === threshold;
|
|
107
|
+
case 'neq': return val !== threshold;
|
|
108
|
+
default: return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Reload rules at runtime (Owner updated alert config)
|
|
113
|
+
reloadRules(rules) {
|
|
114
|
+
this.rules = rules;
|
|
115
|
+
this._state = {};
|
|
116
|
+
for (const r of rules) {
|
|
117
|
+
this._state[r.id] = { firstTriggeredAt: null, fired: false };
|
|
118
|
+
}
|
|
119
|
+
console.log(`[alerts] Rules reloaded: ${rules.length} rule(s)`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = AlertEngine;
|
package/lib/connect.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { io } = require('socket.io-client');
|
|
4
|
+
const store = require('./store');
|
|
5
|
+
|
|
6
|
+
const NAMESPACE = '/devops';
|
|
7
|
+
const RECONNECT_DELAY = 5000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Option B auth flow:
|
|
11
|
+
* 1. Agent has no token yet → sends { agentId, name, hostname } to server
|
|
12
|
+
* 2. Server creates a PENDING agent entry, notifies Owner in room
|
|
13
|
+
* 3. Owner approves in browser → server sends back a signed agentToken
|
|
14
|
+
* 4. Agent stores token in ~/.thinknagent/config.json (mode 600)
|
|
15
|
+
* 5. All future connections: agent sends { agentId, agentToken } → server verifies → ACTIVE
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class Connection {
|
|
19
|
+
constructor({ serverUrl, onReady, onDisconnect, onRoleUpdate }) {
|
|
20
|
+
this.serverUrl = serverUrl;
|
|
21
|
+
this.onReady = onReady; // called when agent is ACTIVE and authed
|
|
22
|
+
this.onDisconnect = onDisconnect;
|
|
23
|
+
this.onRoleUpdate = onRoleUpdate; // called if Owner changes agent permissions
|
|
24
|
+
this.socket = null;
|
|
25
|
+
this.agentId = store.get('agentId');
|
|
26
|
+
this.agentToken = store.get('agentToken');
|
|
27
|
+
this.role = store.get('role') || 'monitor'; // monitor | shell | admin
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connect() {
|
|
31
|
+
const cfg = store.read();
|
|
32
|
+
|
|
33
|
+
this.socket = io(`${this.serverUrl}${NAMESPACE}`, {
|
|
34
|
+
reconnection: true,
|
|
35
|
+
reconnectionDelay: RECONNECT_DELAY,
|
|
36
|
+
reconnectionAttempts: Infinity,
|
|
37
|
+
auth: {
|
|
38
|
+
agentId: cfg.agentId,
|
|
39
|
+
agentToken: cfg.agentToken || null, // null = first time / pending
|
|
40
|
+
name: cfg.name,
|
|
41
|
+
hostname: require('os').hostname(),
|
|
42
|
+
version: require('../package.json').version,
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this._bind();
|
|
47
|
+
return this.socket;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_bind() {
|
|
51
|
+
const s = this.socket;
|
|
52
|
+
|
|
53
|
+
// ── Registration flow (Option B) ─────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
// Server says: "I got your registration, waiting for Owner approval"
|
|
56
|
+
s.on('agent:pending', ({ agentId }) => {
|
|
57
|
+
store.set('agentId', agentId);
|
|
58
|
+
this.agentId = agentId;
|
|
59
|
+
console.log(`[thinknagent] Registered as ${agentId} — waiting for Owner approval in ThinkNCollab...`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Server says: "Owner approved, here is your token + assigned role"
|
|
63
|
+
s.on('agent:approved', ({ agentToken, role, roomId }) => {
|
|
64
|
+
store.set('agentToken', agentToken);
|
|
65
|
+
store.set('agentId', this.agentId);
|
|
66
|
+
store.set('role', role);
|
|
67
|
+
store.set('roomId', roomId);
|
|
68
|
+
this.agentToken = agentToken;
|
|
69
|
+
this.role = role;
|
|
70
|
+
console.log(`[thinknagent] Approved! Role: ${role} | Room: ${roomId}`);
|
|
71
|
+
this.onReady?.({ role, roomId });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Server says: "Owner rejected this agent"
|
|
75
|
+
s.on('agent:rejected', ({ reason }) => {
|
|
76
|
+
console.error(`[thinknagent] Registration rejected: ${reason}`);
|
|
77
|
+
store.clear();
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Already approved on previous run — server confirms active session
|
|
82
|
+
s.on('agent:active', ({ role, roomId }) => {
|
|
83
|
+
this.role = role;
|
|
84
|
+
console.log(`[thinknagent] Reconnected. Role: ${role} | Room: ${roomId}`);
|
|
85
|
+
this.onReady?.({ role, roomId });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Owner changed this agent's role at runtime
|
|
89
|
+
s.on('agent:role_updated', ({ role }) => {
|
|
90
|
+
store.set('role', role);
|
|
91
|
+
this.role = role;
|
|
92
|
+
console.log(`[thinknagent] Role updated to: ${role}`);
|
|
93
|
+
this.onRoleUpdate?.({ role });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Owner revoked this agent
|
|
97
|
+
s.on('agent:revoked', () => {
|
|
98
|
+
console.warn('[thinknagent] Agent revoked by Owner. Clearing credentials.');
|
|
99
|
+
store.clear();
|
|
100
|
+
process.exit(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
s.on('connect_error', (err) => {
|
|
104
|
+
console.error(`[thinknagent] Connection error: ${err.message}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
s.on('disconnect', (reason) => {
|
|
108
|
+
console.warn(`[thinknagent] Disconnected: ${reason}`);
|
|
109
|
+
this.onDisconnect?.({ reason });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Emit helper — checks role before sending sensitive data
|
|
114
|
+
emit(event, data) {
|
|
115
|
+
if (!this.socket?.connected) return;
|
|
116
|
+
this.socket.emit(event, data);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Role gate helper — called by shell.js before opening PTY
|
|
120
|
+
hasRole(required) {
|
|
121
|
+
const hierarchy = { monitor: 0, shell: 1, admin: 2 };
|
|
122
|
+
return (hierarchy[this.role] ?? -1) >= (hierarchy[required] ?? 99);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get connected() {
|
|
126
|
+
return this.socket?.connected ?? false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = Connection;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
6
|
+
|
|
7
|
+
const BUFFER_LINES = 100; // lines to send on initial connect
|
|
8
|
+
const CHUNK_DELAY = 50; // ms debounce before flushing new lines
|
|
9
|
+
|
|
10
|
+
class LogWatcher extends EventEmitter {
|
|
11
|
+
constructor({ connection, logPaths = [] }) {
|
|
12
|
+
super();
|
|
13
|
+
this.conn = connection;
|
|
14
|
+
this.logPaths = logPaths;
|
|
15
|
+
this._watchers = new Map(); // path → { fd, size, timer }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
start() {
|
|
19
|
+
if (!this.logPaths.length) {
|
|
20
|
+
console.log('[logs] No log paths configured — skipping');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const p of this.logPaths) {
|
|
24
|
+
this._watch(p);
|
|
25
|
+
}
|
|
26
|
+
console.log(`[logs] Watching ${this.logPaths.length} file(s)`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
stop() {
|
|
30
|
+
for (const [p, w] of this._watchers) {
|
|
31
|
+
try { fs.unwatchFile(p); } catch {}
|
|
32
|
+
if (w.timer) clearTimeout(w.timer);
|
|
33
|
+
}
|
|
34
|
+
this._watchers.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_watch(filePath) {
|
|
38
|
+
const absPath = path.resolve(filePath);
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(absPath)) {
|
|
41
|
+
console.warn(`[logs] File not found: ${absPath} — will retry when it appears`);
|
|
42
|
+
// retry every 10s in case log file is created later
|
|
43
|
+
setTimeout(() => this._watch(filePath), 10000);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stat = fs.statSync(absPath);
|
|
48
|
+
const state = {
|
|
49
|
+
size: stat.size,
|
|
50
|
+
timer: null,
|
|
51
|
+
lines: [],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Send last BUFFER_LINES lines on startup
|
|
55
|
+
this._sendTail(absPath);
|
|
56
|
+
|
|
57
|
+
fs.watchFile(absPath, { interval: 500 }, (curr, prev) => {
|
|
58
|
+
if (curr.size < prev.size) {
|
|
59
|
+
// Log rotated — reset position
|
|
60
|
+
state.size = 0;
|
|
61
|
+
}
|
|
62
|
+
if (curr.size === prev.size) return;
|
|
63
|
+
|
|
64
|
+
const newBytes = curr.size - state.size;
|
|
65
|
+
if (newBytes <= 0) return;
|
|
66
|
+
|
|
67
|
+
const buf = Buffer.alloc(newBytes);
|
|
68
|
+
const fd = fs.openSync(absPath, 'r');
|
|
69
|
+
fs.readSync(fd, buf, 0, newBytes, state.size);
|
|
70
|
+
fs.closeSync(fd);
|
|
71
|
+
state.size = curr.size;
|
|
72
|
+
|
|
73
|
+
const newLines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
74
|
+
state.lines.push(...newLines);
|
|
75
|
+
|
|
76
|
+
// debounce — batch lines before emit
|
|
77
|
+
clearTimeout(state.timer);
|
|
78
|
+
state.timer = setTimeout(() => {
|
|
79
|
+
const toSend = state.lines.splice(0);
|
|
80
|
+
if (toSend.length) {
|
|
81
|
+
this.conn.emit('agent:logs', {
|
|
82
|
+
file: absPath,
|
|
83
|
+
lines: toSend.map(l => ({ ts: Date.now(), text: l })),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}, CHUNK_DELAY);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
this._watchers.set(absPath, state);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_sendTail(filePath) {
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
95
|
+
const lines = content.split('\n').filter(Boolean).slice(-BUFFER_LINES);
|
|
96
|
+
if (lines.length) {
|
|
97
|
+
this.conn.emit('agent:logs:tail', {
|
|
98
|
+
file: filePath,
|
|
99
|
+
lines: lines.map(l => ({ ts: null, text: l })), // historical = no ts
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`[logs] Failed to read tail for ${filePath}:`, err.message);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = LogWatcher;
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const si = require('systeminformation');
|
|
4
|
+
|
|
5
|
+
const POLL_INTERVAL = 5000; // ms
|
|
6
|
+
|
|
7
|
+
class MetricsPoller {
|
|
8
|
+
constructor({ connection, gpu = false }) {
|
|
9
|
+
this.conn = connection;
|
|
10
|
+
this.gpu = gpu;
|
|
11
|
+
this._timer = null;
|
|
12
|
+
this._history = []; // last 60 data points (5 min rolling)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
start() {
|
|
16
|
+
this._poll(); // immediate first poll
|
|
17
|
+
this._timer = setInterval(() => this._poll(), POLL_INTERVAL);
|
|
18
|
+
console.log('[metrics] Poller started');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stop() {
|
|
22
|
+
if (this._timer) clearInterval(this._timer);
|
|
23
|
+
console.log('[metrics] Poller stopped');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async _poll() {
|
|
27
|
+
try {
|
|
28
|
+
const [cpu, mem, disk, net, load, procs] = await Promise.all([
|
|
29
|
+
si.currentLoad(),
|
|
30
|
+
si.mem(),
|
|
31
|
+
si.fsSize(),
|
|
32
|
+
si.networkStats(),
|
|
33
|
+
si.currentLoad(), // for load average
|
|
34
|
+
si.processes(),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const payload = {
|
|
38
|
+
ts: Date.now(),
|
|
39
|
+
cpu: {
|
|
40
|
+
usage: parseFloat(cpu.currentLoad.toFixed(1)),
|
|
41
|
+
cores: cpu.cpus?.length ?? 0,
|
|
42
|
+
loadAvg: load.avgLoad ?? 0,
|
|
43
|
+
},
|
|
44
|
+
memory: {
|
|
45
|
+
total: mem.total,
|
|
46
|
+
used: mem.used,
|
|
47
|
+
free: mem.free,
|
|
48
|
+
usedPct: parseFloat(((mem.used / mem.total) * 100).toFixed(1)),
|
|
49
|
+
},
|
|
50
|
+
disk: disk.map(d => ({
|
|
51
|
+
fs: d.fs,
|
|
52
|
+
mount: d.mount,
|
|
53
|
+
size: d.size,
|
|
54
|
+
used: d.used,
|
|
55
|
+
usedPct: parseFloat(d.use?.toFixed(1) ?? 0),
|
|
56
|
+
})),
|
|
57
|
+
network: net.map(n => ({
|
|
58
|
+
iface: n.iface,
|
|
59
|
+
rxSec: n.rx_sec ?? 0,
|
|
60
|
+
txSec: n.tx_sec ?? 0,
|
|
61
|
+
})),
|
|
62
|
+
processes: {
|
|
63
|
+
total: procs.all,
|
|
64
|
+
running: procs.running,
|
|
65
|
+
// top 5 by CPU
|
|
66
|
+
top: (procs.list || [])
|
|
67
|
+
.sort((a, b) => b.pcpu - a.pcpu)
|
|
68
|
+
.slice(0, 5)
|
|
69
|
+
.map(p => ({ pid: p.pid, name: p.name, cpu: p.pcpu, mem: p.pmem })),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// optional GPU
|
|
74
|
+
if (this.gpu) {
|
|
75
|
+
try {
|
|
76
|
+
const gpuData = await si.graphics();
|
|
77
|
+
payload.gpu = gpuData.controllers?.map(g => ({
|
|
78
|
+
model: g.model,
|
|
79
|
+
utilizationGpu: g.utilizationGpu ?? null,
|
|
80
|
+
memUsed: g.memoryUsed ?? null,
|
|
81
|
+
memTotal: g.memoryTotal ?? null,
|
|
82
|
+
tempC: g.temperatureGpu ?? null,
|
|
83
|
+
}));
|
|
84
|
+
} catch {
|
|
85
|
+
// nvidia-smi not available — silently skip
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// rolling history (keep last 60 points)
|
|
90
|
+
this._history.push({ ts: payload.ts, cpu: payload.cpu.usage, mem: payload.memory.usedPct });
|
|
91
|
+
if (this._history.length > 60) this._history.shift();
|
|
92
|
+
|
|
93
|
+
payload.history = this._history;
|
|
94
|
+
|
|
95
|
+
this.conn.emit('agent:metrics', payload);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('[metrics] Poll error:', err.message);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = MetricsPoller;
|
package/lib/shell.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let pty;
|
|
4
|
+
try {
|
|
5
|
+
pty = require('node-pty');
|
|
6
|
+
} catch {
|
|
7
|
+
pty = null; // node-pty is optional — shell feature disabled if not installed
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shell bridge — opens a PTY session on this server and relays I/O
|
|
12
|
+
* bidirectionally over Socket.IO to the DevOps Wall xterm.js terminal.
|
|
13
|
+
*
|
|
14
|
+
* Role gate: connection.hasRole('shell') must be true.
|
|
15
|
+
* If the agent role is 'monitor' only, all shell:open events are rejected.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class ShellBridge {
|
|
19
|
+
constructor({ connection }) {
|
|
20
|
+
this.conn = connection;
|
|
21
|
+
this._sessions = new Map(); // sessionId → ptyProcess
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start() {
|
|
25
|
+
if (!pty) {
|
|
26
|
+
console.warn('[shell] node-pty not installed — shell feature disabled');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const s = this.conn.socket;
|
|
31
|
+
|
|
32
|
+
// Browser requests a new shell session
|
|
33
|
+
s.on('shell:open', ({ sessionId, cols = 80, rows = 24 }) => {
|
|
34
|
+
if (!this.conn.hasRole('shell')) {
|
|
35
|
+
s.emit('shell:error', { sessionId, reason: 'Insufficient role — shell access not granted for this agent' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (this._sessions.has(sessionId)) return; // already open
|
|
39
|
+
|
|
40
|
+
const proc = pty.spawn(process.env.SHELL || '/bin/bash', [], {
|
|
41
|
+
name: 'xterm-256color',
|
|
42
|
+
cols,
|
|
43
|
+
rows,
|
|
44
|
+
cwd: process.env.HOME || '/',
|
|
45
|
+
env: {
|
|
46
|
+
...process.env,
|
|
47
|
+
TERM: 'xterm-256color',
|
|
48
|
+
THINKNCOLLAB_AGENT: '1', // agent can inject this env so shell scripts know
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
proc.onData(data => {
|
|
53
|
+
s.emit('shell:data', { sessionId, data });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
proc.onExit(({ exitCode }) => {
|
|
57
|
+
s.emit('shell:exit', { sessionId, exitCode });
|
|
58
|
+
this._sessions.delete(sessionId);
|
|
59
|
+
console.log(`[shell] Session ${sessionId} exited (code ${exitCode})`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this._sessions.set(sessionId, proc);
|
|
63
|
+
s.emit('shell:opened', { sessionId });
|
|
64
|
+
console.log(`[shell] Session ${sessionId} opened (${cols}x${rows})`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Browser → server → agent: user typed something
|
|
68
|
+
s.on('shell:input', ({ sessionId, data }) => {
|
|
69
|
+
const proc = this._sessions.get(sessionId);
|
|
70
|
+
if (!proc) return;
|
|
71
|
+
proc.write(data);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Browser resized terminal
|
|
75
|
+
s.on('shell:resize', ({ sessionId, cols, rows }) => {
|
|
76
|
+
const proc = this._sessions.get(sessionId);
|
|
77
|
+
if (!proc) return;
|
|
78
|
+
proc.resize(cols, rows);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Browser closed terminal tab
|
|
82
|
+
s.on('shell:close', ({ sessionId }) => {
|
|
83
|
+
this._killSession(sessionId);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
console.log('[shell] Bridge ready');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_killSession(sessionId) {
|
|
90
|
+
const proc = this._sessions.get(sessionId);
|
|
91
|
+
if (proc) {
|
|
92
|
+
try { proc.kill(); } catch {}
|
|
93
|
+
this._sessions.delete(sessionId);
|
|
94
|
+
console.log(`[shell] Session ${sessionId} killed`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Kill all sessions on disconnect
|
|
99
|
+
killAll() {
|
|
100
|
+
for (const [id] of this._sessions) this._killSession(id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = ShellBridge;
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.thinknagent');
|
|
8
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
const LOG_FILE = path.join(CONFIG_DIR, 'agent.log');
|
|
10
|
+
|
|
11
|
+
function ensureDir() {
|
|
12
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
13
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); // owner-only
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function read() {
|
|
18
|
+
ensureDir();
|
|
19
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function write(data) {
|
|
28
|
+
ensureDir();
|
|
29
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), { mode: 0o600 }); // owner read/write only
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function get(key) {
|
|
33
|
+
return read()[key];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function set(key, value) {
|
|
37
|
+
const cfg = read();
|
|
38
|
+
cfg[key] = value;
|
|
39
|
+
write(cfg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clear() {
|
|
43
|
+
if (fs.existsSync(CONFIG_FILE)) fs.unlinkSync(CONFIG_FILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { CONFIG_DIR, CONFIG_FILE, LOG_FILE, read, write, get, set, clear };
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "thinknagent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ThinkNCollab server agent — metrics, logs, alerts, shell bridge",
|
|
5
|
+
"main": "lib/agent.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"thinknagent": "./bin/thinknagent.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/thinknagent.js start",
|
|
11
|
+
"dev": "node bin/thinknagent.js start --dev"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"socket.io-client": "^4.7.5",
|
|
15
|
+
"node-pty": "^1.0.0",
|
|
16
|
+
"systeminformation": "^5.22.7",
|
|
17
|
+
"chokidar": "^3.6.0",
|
|
18
|
+
"chalk": "^4.1.2",
|
|
19
|
+
"commander": "^12.1.0",
|
|
20
|
+
"conf": "^10.2.0",
|
|
21
|
+
"ora": "^5.4.1",
|
|
22
|
+
"uuid": "^10.0.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": ["thinkncollab", "devops", "monitoring", "agent"],
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|