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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/index.js +410 -0
  4. 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
+ }