indieclaw-agent 1.0.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/LICENSE +21 -0
- package/README.md +110 -0
- package/index.js +410 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammet Arslantas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# IndieClaw Agent
|
|
2
|
+
|
|
3
|
+
Manage your server from your phone. This is the server-side agent for the [IndieClaw](https://github.com/muhammetarslantas/indieclaw) mobile app.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx indieclaw-agent
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's it. The agent starts a WebSocket server on port 3100 and prints an auth token. Enter the token in the IndieClaw mobile app to connect.
|
|
12
|
+
|
|
13
|
+
## What It Does
|
|
14
|
+
|
|
15
|
+
The agent runs on your server and lets the mobile app:
|
|
16
|
+
|
|
17
|
+
- **Dashboard** — CPU, RAM, disk usage, uptime
|
|
18
|
+
- **File Browser** — Browse, read, and edit files
|
|
19
|
+
- **Docker** — List containers, view logs, start/stop/restart
|
|
20
|
+
- **Terminal** — Full interactive shell (requires `node-pty`)
|
|
21
|
+
- **Cron Jobs** — View scheduled tasks
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- **Node.js 18+**
|
|
26
|
+
- **Docker** (optional — for container management)
|
|
27
|
+
- **node-pty build tools** (optional — for interactive terminal)
|
|
28
|
+
|
|
29
|
+
## Install Globally (alternative)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -g indieclaw-agent
|
|
33
|
+
indieclaw-agent
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
| Environment Variable | Default | Description |
|
|
39
|
+
|---------------------|---------|-------------|
|
|
40
|
+
| `INDIECLAW_PORT` | `3100` | WebSocket server port |
|
|
41
|
+
| `INDIECLAW_TOKEN` | auto-generated | Custom auth token |
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Custom port
|
|
45
|
+
INDIECLAW_PORT=4000 npx indieclaw-agent
|
|
46
|
+
|
|
47
|
+
# Custom token
|
|
48
|
+
INDIECLAW_TOKEN=mysecrettoken npx indieclaw-agent
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Network Setup
|
|
52
|
+
|
|
53
|
+
Your phone needs to reach the agent. Options:
|
|
54
|
+
|
|
55
|
+
| Method | Connection URL | Setup |
|
|
56
|
+
|--------|---------------|-------|
|
|
57
|
+
| **Tailscale** (recommended) | `ws://100.x.x.x:3100` | Install Tailscale on both devices |
|
|
58
|
+
| **Direct IP** | `ws://your-vps-ip:3100` | Open port 3100 in firewall |
|
|
59
|
+
| **Local network** | `ws://192.168.x.x:3100` | Same WiFi, no setup needed |
|
|
60
|
+
|
|
61
|
+
## Interactive Terminal
|
|
62
|
+
|
|
63
|
+
For the full terminal feature, the agent needs `node-pty` which requires native build tools:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Ubuntu/Debian
|
|
67
|
+
sudo apt install build-essential python3
|
|
68
|
+
|
|
69
|
+
# macOS
|
|
70
|
+
xcode-select --install
|
|
71
|
+
|
|
72
|
+
# Then install globally for best results
|
|
73
|
+
npm install -g indieclaw-agent
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Without `node-pty`, everything works except the Terminal tab. The agent will show a message on startup if it's not available.
|
|
77
|
+
|
|
78
|
+
## Security
|
|
79
|
+
|
|
80
|
+
- Auth token is generated on first run and stored in `~/.indieclaw-token`
|
|
81
|
+
- All WebSocket messages require authentication
|
|
82
|
+
- The agent only listens for connections — it never phones home
|
|
83
|
+
- Use Tailscale or a firewall to restrict who can reach port 3100
|
|
84
|
+
|
|
85
|
+
## Run as a Service (systemd)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
sudo tee /etc/systemd/system/indieclaw-agent.service > /dev/null <<EOF
|
|
89
|
+
[Unit]
|
|
90
|
+
Description=IndieClaw Agent
|
|
91
|
+
After=network.target
|
|
92
|
+
|
|
93
|
+
[Service]
|
|
94
|
+
Type=simple
|
|
95
|
+
User=$USER
|
|
96
|
+
ExecStart=$(which npx) indieclaw-agent
|
|
97
|
+
Restart=always
|
|
98
|
+
RestartSec=10
|
|
99
|
+
|
|
100
|
+
[Install]
|
|
101
|
+
WantedBy=multi-user.target
|
|
102
|
+
EOF
|
|
103
|
+
|
|
104
|
+
sudo systemctl enable indieclaw-agent
|
|
105
|
+
sudo systemctl start indieclaw-agent
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { WebSocketServer } = require('ws');
|
|
4
|
+
const { execSync, exec } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
// --- Configuration ---
|
|
11
|
+
const PORT = parseInt(process.env.INDIECLAW_PORT || '3100', 10);
|
|
12
|
+
const TOKEN_FILE = path.join(os.homedir(), '.indieclaw-token');
|
|
13
|
+
|
|
14
|
+
function getOrCreateToken() {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
|
|
17
|
+
} catch {
|
|
18
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
19
|
+
fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 });
|
|
20
|
+
return token;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AUTH_TOKEN = process.env.INDIECLAW_TOKEN || getOrCreateToken();
|
|
25
|
+
|
|
26
|
+
// --- PTY setup (optional, graceful fallback) ---
|
|
27
|
+
let pty;
|
|
28
|
+
try {
|
|
29
|
+
pty = require('node-pty');
|
|
30
|
+
} catch {
|
|
31
|
+
console.log('[agent] node-pty not available — terminal feature disabled');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- WebSocket Server ---
|
|
35
|
+
const wss = new WebSocketServer({ port: PORT });
|
|
36
|
+
const terminals = new Map(); // id -> pty process
|
|
37
|
+
|
|
38
|
+
console.log('');
|
|
39
|
+
console.log(' ╔═══════════════════════════════════════╗');
|
|
40
|
+
console.log(' ║ IndieClaw Agent v1.0.0 ║');
|
|
41
|
+
console.log(' ╠═══════════════════════════════════════╣');
|
|
42
|
+
console.log(` ║ Port: ${PORT} ║`);
|
|
43
|
+
console.log(` ║ Token: ${AUTH_TOKEN.substring(0, 12)}... ║`);
|
|
44
|
+
console.log(' ╚═══════════════════════════════════════╝');
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log(` Full token: ${AUTH_TOKEN}`);
|
|
47
|
+
console.log(' Enter this token in the IndieClaw mobile app to connect.');
|
|
48
|
+
console.log('');
|
|
49
|
+
|
|
50
|
+
wss.on('connection', (ws) => {
|
|
51
|
+
let authenticated = false;
|
|
52
|
+
|
|
53
|
+
ws.on('message', (raw) => {
|
|
54
|
+
let msg;
|
|
55
|
+
try {
|
|
56
|
+
msg = JSON.parse(raw.toString());
|
|
57
|
+
} catch {
|
|
58
|
+
return ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Auth check
|
|
62
|
+
if (!authenticated) {
|
|
63
|
+
if (msg.type === 'auth' && msg.token === AUTH_TOKEN) {
|
|
64
|
+
authenticated = true;
|
|
65
|
+
return ws.send(JSON.stringify({ type: 'auth', success: true }));
|
|
66
|
+
}
|
|
67
|
+
if (msg.type === 'ping') {
|
|
68
|
+
return ws.send(JSON.stringify({ type: 'pong' }));
|
|
69
|
+
}
|
|
70
|
+
return ws.send(JSON.stringify({ type: 'auth', success: false, error: 'Unauthorized' }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Handle ping
|
|
74
|
+
if (msg.type === 'ping') {
|
|
75
|
+
return ws.send(JSON.stringify({ type: 'pong' }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Route message
|
|
79
|
+
handleMessage(ws, msg);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
ws.on('close', () => {
|
|
83
|
+
// Clean up any terminals owned by this connection
|
|
84
|
+
for (const [id, term] of terminals) {
|
|
85
|
+
if (term._ws === ws) {
|
|
86
|
+
term.kill();
|
|
87
|
+
terminals.delete(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function send(ws, msg) {
|
|
94
|
+
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reply(ws, id, data) {
|
|
98
|
+
send(ws, { type: 'result', id, success: true, data });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function replyError(ws, id, error) {
|
|
102
|
+
send(ws, { type: 'result', id, success: false, error });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function handleMessage(ws, msg) {
|
|
106
|
+
const { type, id } = msg;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
switch (type) {
|
|
110
|
+
case 'exec':
|
|
111
|
+
return handleExec(ws, msg);
|
|
112
|
+
case 'fs.list':
|
|
113
|
+
return handleFsList(ws, msg);
|
|
114
|
+
case 'fs.read':
|
|
115
|
+
return handleFsRead(ws, msg);
|
|
116
|
+
case 'fs.write':
|
|
117
|
+
return handleFsWrite(ws, msg);
|
|
118
|
+
case 'fs.delete':
|
|
119
|
+
return handleFsDelete(ws, msg);
|
|
120
|
+
case 'system.stats':
|
|
121
|
+
return handleSystemStats(ws, msg);
|
|
122
|
+
case 'docker.list':
|
|
123
|
+
return handleDockerList(ws, msg);
|
|
124
|
+
case 'docker.logs':
|
|
125
|
+
return handleDockerLogs(ws, msg);
|
|
126
|
+
case 'docker.start':
|
|
127
|
+
return handleDockerAction(ws, msg, 'start');
|
|
128
|
+
case 'docker.stop':
|
|
129
|
+
return handleDockerAction(ws, msg, 'stop');
|
|
130
|
+
case 'docker.restart':
|
|
131
|
+
return handleDockerAction(ws, msg, 'restart');
|
|
132
|
+
case 'cron.list':
|
|
133
|
+
return handleCronList(ws, msg);
|
|
134
|
+
case 'terminal.start':
|
|
135
|
+
return handleTerminalStart(ws, msg);
|
|
136
|
+
case 'terminal.input':
|
|
137
|
+
return handleTerminalInput(ws, msg);
|
|
138
|
+
case 'terminal.resize':
|
|
139
|
+
return handleTerminalResize(ws, msg);
|
|
140
|
+
case 'terminal.stop':
|
|
141
|
+
return handleTerminalStop(ws, msg);
|
|
142
|
+
default:
|
|
143
|
+
return replyError(ws, id, `Unknown message type: ${type}`);
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
replyError(ws, id, err.message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Command Execution ---
|
|
151
|
+
function handleExec(ws, { id, command }) {
|
|
152
|
+
exec(command, { timeout: 30000, maxBuffer: 1024 * 1024 * 5 }, (err, stdout, stderr) => {
|
|
153
|
+
if (err && !stdout && !stderr) {
|
|
154
|
+
return replyError(ws, id, err.message);
|
|
155
|
+
}
|
|
156
|
+
reply(ws, id, { stdout: stdout || '', stderr: stderr || '', exitCode: err ? err.code : 0 });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- File System ---
|
|
161
|
+
function handleFsList(ws, { id, path: dirPath }) {
|
|
162
|
+
const targetPath = dirPath || os.homedir();
|
|
163
|
+
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
|
|
164
|
+
const result = entries.map((entry) => {
|
|
165
|
+
const fullPath = path.join(targetPath, entry.name);
|
|
166
|
+
let stats;
|
|
167
|
+
try {
|
|
168
|
+
stats = fs.statSync(fullPath);
|
|
169
|
+
} catch {
|
|
170
|
+
stats = null;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
name: entry.name,
|
|
174
|
+
isDirectory: entry.isDirectory(),
|
|
175
|
+
size: stats ? stats.size : 0,
|
|
176
|
+
modified: stats ? stats.mtime.toISOString() : null,
|
|
177
|
+
permissions: stats ? '0' + (stats.mode & 0o777).toString(8) : null,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
reply(ws, id, result);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function handleFsRead(ws, { id, path: filePath }) {
|
|
184
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
185
|
+
reply(ws, id, { content, size: Buffer.byteLength(content) });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleFsWrite(ws, { id, path: filePath, content }) {
|
|
189
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
190
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
191
|
+
reply(ws, id, { written: Buffer.byteLength(content) });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handleFsDelete(ws, { id, path: filePath }) {
|
|
195
|
+
const stat = fs.statSync(filePath);
|
|
196
|
+
if (stat.isDirectory()) {
|
|
197
|
+
fs.rmSync(filePath, { recursive: true });
|
|
198
|
+
} else {
|
|
199
|
+
fs.unlinkSync(filePath);
|
|
200
|
+
}
|
|
201
|
+
reply(ws, id, { deleted: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- System Stats ---
|
|
205
|
+
function handleSystemStats(ws, { id }) {
|
|
206
|
+
const platform = os.platform();
|
|
207
|
+
const stats = {
|
|
208
|
+
hostname: os.hostname(),
|
|
209
|
+
platform,
|
|
210
|
+
uptime: os.uptime(),
|
|
211
|
+
loadAvg: os.loadavg(),
|
|
212
|
+
cpu: {
|
|
213
|
+
model: os.cpus()[0]?.model || 'Unknown',
|
|
214
|
+
cores: os.cpus().length,
|
|
215
|
+
usage: getCpuUsage(platform),
|
|
216
|
+
},
|
|
217
|
+
memory: {
|
|
218
|
+
total: os.totalmem(),
|
|
219
|
+
free: os.freemem(),
|
|
220
|
+
used: os.totalmem() - os.freemem(),
|
|
221
|
+
usagePercent: Math.round(((os.totalmem() - os.freemem()) / os.totalmem()) * 100),
|
|
222
|
+
},
|
|
223
|
+
disk: getDiskUsage(platform),
|
|
224
|
+
};
|
|
225
|
+
reply(ws, id, stats);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getCpuUsage(platform) {
|
|
229
|
+
try {
|
|
230
|
+
if (platform === 'linux') {
|
|
231
|
+
const load = os.loadavg()[0];
|
|
232
|
+
const cores = os.cpus().length;
|
|
233
|
+
return Math.min(Math.round((load / cores) * 100), 100);
|
|
234
|
+
}
|
|
235
|
+
if (platform === 'darwin') {
|
|
236
|
+
const out = execSync("top -l 1 -n 0 | grep 'CPU usage'", { timeout: 5000 }).toString();
|
|
237
|
+
const match = out.match(/([\d.]+)% idle/);
|
|
238
|
+
if (match) return Math.round(100 - parseFloat(match[1]));
|
|
239
|
+
}
|
|
240
|
+
return Math.min(Math.round((os.loadavg()[0] / os.cpus().length) * 100), 100);
|
|
241
|
+
} catch {
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getDiskUsage(platform) {
|
|
247
|
+
try {
|
|
248
|
+
const cmd = platform === 'darwin' ? 'df -k /' : 'df -k / --output=source,size,used,avail,pcent,target';
|
|
249
|
+
const out = execSync(cmd, { timeout: 5000 }).toString();
|
|
250
|
+
const lines = out.trim().split('\n').slice(1);
|
|
251
|
+
return lines.map((line) => {
|
|
252
|
+
const parts = line.trim().split(/\s+/);
|
|
253
|
+
if (platform === 'darwin') {
|
|
254
|
+
// macOS df: Filesystem 512-blocks Used Available Capacity ...
|
|
255
|
+
// df -k gives 1K-blocks
|
|
256
|
+
return {
|
|
257
|
+
filesystem: parts[0],
|
|
258
|
+
mount: parts[8] || parts[5] || '/',
|
|
259
|
+
total: parseInt(parts[1], 10) * 1024,
|
|
260
|
+
used: parseInt(parts[2], 10) * 1024,
|
|
261
|
+
available: parseInt(parts[3], 10) * 1024,
|
|
262
|
+
usagePercent: parseInt(parts[4], 10),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
filesystem: parts[0],
|
|
267
|
+
total: parseInt(parts[1], 10) * 1024,
|
|
268
|
+
used: parseInt(parts[2], 10) * 1024,
|
|
269
|
+
available: parseInt(parts[3], 10) * 1024,
|
|
270
|
+
usagePercent: parseInt(parts[4], 10),
|
|
271
|
+
mount: parts[5] || '/',
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- Docker ---
|
|
280
|
+
function handleDockerList(ws, { id }) {
|
|
281
|
+
exec(
|
|
282
|
+
'docker ps -a --format "{{json .}}"',
|
|
283
|
+
{ timeout: 10000 },
|
|
284
|
+
(err, stdout) => {
|
|
285
|
+
if (err) return replyError(ws, id, 'Docker not available or not running');
|
|
286
|
+
const containers = stdout
|
|
287
|
+
.trim()
|
|
288
|
+
.split('\n')
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.map((line) => {
|
|
291
|
+
const c = JSON.parse(line);
|
|
292
|
+
return {
|
|
293
|
+
id: c.ID,
|
|
294
|
+
name: c.Names,
|
|
295
|
+
image: c.Image,
|
|
296
|
+
status: c.Status,
|
|
297
|
+
state: c.State,
|
|
298
|
+
ports: c.Ports,
|
|
299
|
+
created: c.CreatedAt,
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
reply(ws, id, containers);
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function handleDockerLogs(ws, { id, containerId, lines = 100 }) {
|
|
308
|
+
exec(
|
|
309
|
+
`docker logs --tail ${lines} ${containerId}`,
|
|
310
|
+
{ timeout: 10000, maxBuffer: 1024 * 1024 * 5 },
|
|
311
|
+
(err, stdout, stderr) => {
|
|
312
|
+
if (err) return replyError(ws, id, err.message);
|
|
313
|
+
reply(ws, id, { logs: stdout + stderr });
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handleDockerAction(ws, { id, containerId }, action) {
|
|
319
|
+
exec(`docker ${action} ${containerId}`, { timeout: 30000 }, (err, stdout) => {
|
|
320
|
+
if (err) return replyError(ws, id, err.message);
|
|
321
|
+
reply(ws, id, { success: true, output: stdout.trim() });
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- Cron ---
|
|
326
|
+
function handleCronList(ws, { id }) {
|
|
327
|
+
exec('crontab -l', { timeout: 5000 }, (err, stdout) => {
|
|
328
|
+
if (err) {
|
|
329
|
+
if (err.message.includes('no crontab')) {
|
|
330
|
+
return reply(ws, id, []);
|
|
331
|
+
}
|
|
332
|
+
return replyError(ws, id, err.message);
|
|
333
|
+
}
|
|
334
|
+
const jobs = stdout
|
|
335
|
+
.trim()
|
|
336
|
+
.split('\n')
|
|
337
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
338
|
+
.map((line) => {
|
|
339
|
+
const parts = line.trim().split(/\s+/);
|
|
340
|
+
const schedule = parts.slice(0, 5).join(' ');
|
|
341
|
+
const command = parts.slice(5).join(' ');
|
|
342
|
+
return { schedule, command, raw: line.trim() };
|
|
343
|
+
});
|
|
344
|
+
reply(ws, id, jobs);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Terminal (PTY) ---
|
|
349
|
+
function handleTerminalStart(ws, { id }) {
|
|
350
|
+
if (!pty) {
|
|
351
|
+
return replyError(ws, id, 'Terminal not available (node-pty not installed)');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
355
|
+
const term = pty.spawn(shell, [], {
|
|
356
|
+
name: 'xterm-256color',
|
|
357
|
+
cols: 80,
|
|
358
|
+
rows: 24,
|
|
359
|
+
cwd: os.homedir(),
|
|
360
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
term._ws = ws;
|
|
364
|
+
terminals.set(id, term);
|
|
365
|
+
|
|
366
|
+
term.onData((data) => {
|
|
367
|
+
send(ws, { type: 'terminal.output', id, data });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
term.onExit(({ exitCode }) => {
|
|
371
|
+
send(ws, { type: 'terminal.exit', id, exitCode });
|
|
372
|
+
terminals.delete(id);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
reply(ws, id, { pid: term.pid });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function handleTerminalInput(ws, { id, data }) {
|
|
379
|
+
const term = terminals.get(id);
|
|
380
|
+
if (!term) return replyError(ws, id, 'Terminal not found');
|
|
381
|
+
term.write(data);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function handleTerminalResize(ws, { id, cols, rows }) {
|
|
385
|
+
const term = terminals.get(id);
|
|
386
|
+
if (!term) return replyError(ws, id, 'Terminal not found');
|
|
387
|
+
term.resize(cols, rows);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function handleTerminalStop(ws, { id }) {
|
|
391
|
+
const term = terminals.get(id);
|
|
392
|
+
if (!term) return replyError(ws, id, 'Terminal not found');
|
|
393
|
+
term.kill();
|
|
394
|
+
terminals.delete(id);
|
|
395
|
+
reply(ws, id, { stopped: true });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// --- Graceful Shutdown ---
|
|
399
|
+
process.on('SIGINT', () => {
|
|
400
|
+
console.log('\n[agent] Shutting down...');
|
|
401
|
+
for (const [, term] of terminals) term.kill();
|
|
402
|
+
wss.close();
|
|
403
|
+
process.exit(0);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
process.on('SIGTERM', () => {
|
|
407
|
+
for (const [, term] of terminals) term.kill();
|
|
408
|
+
wss.close();
|
|
409
|
+
process.exit(0);
|
|
410
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "indieclaw-agent",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Manage your server from your phone. Agent for the IndieClaw mobile app.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"indieclaw-agent": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"vps",
|
|
14
|
+
"server",
|
|
15
|
+
"management",
|
|
16
|
+
"mobile",
|
|
17
|
+
"websocket",
|
|
18
|
+
"docker",
|
|
19
|
+
"terminal",
|
|
20
|
+
"monitoring",
|
|
21
|
+
"indieclaw"
|
|
22
|
+
],
|
|
23
|
+
"author": "Muhammet Arslantas",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/muhammetarslantas/indieclaw-agent"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/muhammetarslantas/indieclaw-agent#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/muhammetarslantas/indieclaw-agent/issues"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"ws": "^8.0.0"
|
|
38
|
+
},
|
|
39
|
+
"optionalDependencies": {
|
|
40
|
+
"node-pty": "^1.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|