tlc-claude-code 2.0.1 → 2.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.
Files changed (86) hide show
  1. package/.claude/commands/tlc/deploy.md +194 -2
  2. package/.claude/commands/tlc/e2e-verify.md +214 -0
  3. package/.claude/commands/tlc/guard.md +191 -0
  4. package/.claude/commands/tlc/help.md +32 -0
  5. package/.claude/commands/tlc/init.md +73 -37
  6. package/.claude/commands/tlc/llm.md +19 -4
  7. package/.claude/commands/tlc/preflight.md +134 -0
  8. package/.claude/commands/tlc/review.md +17 -4
  9. package/.claude/commands/tlc/watchci.md +159 -0
  10. package/.claude/hooks/tlc-block-tools.sh +41 -0
  11. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  12. package/.claude/hooks/tlc-post-build.sh +38 -0
  13. package/.claude/hooks/tlc-post-push.sh +22 -0
  14. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  15. package/.claude/hooks/tlc-session-init.sh +123 -0
  16. package/CLAUDE.md +12 -0
  17. package/bin/install.js +171 -2
  18. package/bin/postinstall.js +45 -26
  19. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  20. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  21. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  22. package/dashboard-web/dist/index.html +2 -2
  23. package/docker-compose.dev.yml +18 -12
  24. package/package.json +3 -1
  25. package/server/index.js +228 -2
  26. package/server/lib/capture-bridge.js +242 -0
  27. package/server/lib/capture-bridge.test.js +363 -0
  28. package/server/lib/capture-guard.js +140 -0
  29. package/server/lib/capture-guard.test.js +182 -0
  30. package/server/lib/command-runner.js +159 -0
  31. package/server/lib/command-runner.test.js +92 -0
  32. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  33. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  34. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  35. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  36. package/server/lib/deploy/security-gates.js +11 -24
  37. package/server/lib/deploy/security-gates.test.js +9 -2
  38. package/server/lib/deploy-engine.js +182 -0
  39. package/server/lib/deploy-engine.test.js +147 -0
  40. package/server/lib/docker-api.js +137 -0
  41. package/server/lib/docker-api.test.js +202 -0
  42. package/server/lib/docker-client.js +297 -0
  43. package/server/lib/docker-client.test.js +308 -0
  44. package/server/lib/input-sanitizer.js +86 -0
  45. package/server/lib/input-sanitizer.test.js +117 -0
  46. package/server/lib/launchd-agent.js +225 -0
  47. package/server/lib/launchd-agent.test.js +185 -0
  48. package/server/lib/memory-api.js +3 -1
  49. package/server/lib/memory-api.test.js +3 -5
  50. package/server/lib/memory-bridge-e2e.test.js +160 -0
  51. package/server/lib/memory-committer.js +18 -4
  52. package/server/lib/memory-committer.test.js +21 -0
  53. package/server/lib/memory-hooks-capture.test.js +69 -4
  54. package/server/lib/memory-hooks-integration.test.js +98 -0
  55. package/server/lib/memory-hooks.js +42 -4
  56. package/server/lib/memory-store-adapter.js +105 -0
  57. package/server/lib/memory-store-adapter.test.js +141 -0
  58. package/server/lib/memory-wiring-e2e.test.js +93 -0
  59. package/server/lib/nginx-config.js +114 -0
  60. package/server/lib/nginx-config.test.js +82 -0
  61. package/server/lib/ollama-health.js +91 -0
  62. package/server/lib/ollama-health.test.js +74 -0
  63. package/server/lib/port-guard.js +44 -0
  64. package/server/lib/port-guard.test.js +65 -0
  65. package/server/lib/project-scanner.js +37 -2
  66. package/server/lib/project-scanner.test.js +152 -0
  67. package/server/lib/remember-command.js +2 -0
  68. package/server/lib/remember-command.test.js +23 -0
  69. package/server/lib/security/crypto-utils.test.js +2 -2
  70. package/server/lib/semantic-recall.js +1 -1
  71. package/server/lib/semantic-recall.test.js +17 -0
  72. package/server/lib/ssh-client.js +184 -0
  73. package/server/lib/ssh-client.test.js +127 -0
  74. package/server/lib/vps-api.js +184 -0
  75. package/server/lib/vps-api.test.js +208 -0
  76. package/server/lib/vps-bootstrap.js +124 -0
  77. package/server/lib/vps-bootstrap.test.js +79 -0
  78. package/server/lib/vps-monitor.js +126 -0
  79. package/server/lib/vps-monitor.test.js +98 -0
  80. package/server/lib/workspace-api.js +182 -1
  81. package/server/lib/workspace-api.test.js +474 -0
  82. package/server/package-lock.json +737 -0
  83. package/server/package.json +3 -0
  84. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  85. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  86. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
7
+
8
+ const { createVpsRouter } = await import('./vps-api.js');
9
+
10
+ function createMockSshClient() {
11
+ return {
12
+ exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
13
+ execStream: vi.fn().mockResolvedValue(),
14
+ testConnection: vi.fn().mockResolvedValue({
15
+ connected: true,
16
+ os: 'Linux',
17
+ docker: 'Docker version 24.0.0',
18
+ disk: '42%',
19
+ }),
20
+ upload: vi.fn().mockResolvedValue(),
21
+ };
22
+ }
23
+
24
+ describe('VPS API Router', () => {
25
+ let app;
26
+ let mockSsh;
27
+ let tempDir;
28
+ let vpsJsonPath;
29
+
30
+ beforeEach(() => {
31
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vps-api-test-'));
32
+ vpsJsonPath = path.join(tempDir, 'vps.json');
33
+ mockSsh = createMockSshClient();
34
+
35
+ const router = createVpsRouter({
36
+ sshClient: mockSsh,
37
+ configDir: tempDir,
38
+ });
39
+
40
+ app = express();
41
+ app.use(express.json());
42
+ app.use('/vps', router);
43
+ });
44
+
45
+ afterEach(() => {
46
+ fs.rmSync(tempDir, { recursive: true, force: true });
47
+ });
48
+
49
+ describe('GET /vps/servers', () => {
50
+ it('returns empty list when no servers registered', async () => {
51
+ const res = await request(app).get('/vps/servers');
52
+ expect(res.status).toBe(200);
53
+ expect(res.body).toEqual([]);
54
+ });
55
+
56
+ it('returns registered servers', async () => {
57
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
58
+ servers: [{ id: '1', name: 'dev-1', host: '1.2.3.4', port: 22, username: 'deploy' }],
59
+ }));
60
+ const res = await request(app).get('/vps/servers');
61
+ expect(res.status).toBe(200);
62
+ expect(res.body).toHaveLength(1);
63
+ expect(res.body[0].name).toBe('dev-1');
64
+ });
65
+ });
66
+
67
+ describe('POST /vps/servers', () => {
68
+ it('creates a new server with UUID', async () => {
69
+ const res = await request(app).post('/vps/servers').send({
70
+ name: 'dev-1',
71
+ host: '1.2.3.4',
72
+ port: 22,
73
+ username: 'deploy',
74
+ privateKeyPath: '~/.ssh/id_rsa',
75
+ domain: 'myapp.dev',
76
+ });
77
+ expect(res.status).toBe(201);
78
+ expect(res.body.id).toBeTruthy();
79
+ expect(res.body.name).toBe('dev-1');
80
+ expect(res.body.host).toBe('1.2.3.4');
81
+
82
+ // Verify persisted
83
+ const data = JSON.parse(fs.readFileSync(vpsJsonPath, 'utf8'));
84
+ expect(data.servers).toHaveLength(1);
85
+ });
86
+
87
+ it('validates required fields', async () => {
88
+ const res = await request(app).post('/vps/servers').send({ name: 'dev-1' });
89
+ expect(res.status).toBe(400);
90
+ expect(res.body.error).toBeTruthy();
91
+ });
92
+ });
93
+
94
+ describe('GET /vps/servers/:id', () => {
95
+ it('returns server detail', async () => {
96
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
97
+ servers: [{ id: 'abc', name: 'dev-1', host: '1.2.3.4', port: 22, username: 'deploy' }],
98
+ }));
99
+ const res = await request(app).get('/vps/servers/abc');
100
+ expect(res.status).toBe(200);
101
+ expect(res.body.id).toBe('abc');
102
+ });
103
+
104
+ it('returns 404 for unknown server', async () => {
105
+ const res = await request(app).get('/vps/servers/nonexistent');
106
+ expect(res.status).toBe(404);
107
+ });
108
+ });
109
+
110
+ describe('PUT /vps/servers/:id', () => {
111
+ it('updates server config', async () => {
112
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
113
+ servers: [{ id: 'abc', name: 'dev-1', host: '1.2.3.4', port: 22, username: 'deploy' }],
114
+ }));
115
+ const res = await request(app).put('/vps/servers/abc').send({ name: 'dev-updated' });
116
+ expect(res.status).toBe(200);
117
+ expect(res.body.name).toBe('dev-updated');
118
+
119
+ // Verify persisted
120
+ const data = JSON.parse(fs.readFileSync(vpsJsonPath, 'utf8'));
121
+ expect(data.servers[0].name).toBe('dev-updated');
122
+ });
123
+ });
124
+
125
+ describe('DELETE /vps/servers/:id', () => {
126
+ it('removes server', async () => {
127
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
128
+ servers: [{ id: 'abc', name: 'dev-1', host: '1.2.3.4', port: 22, username: 'deploy' }],
129
+ }));
130
+ const res = await request(app).delete('/vps/servers/abc');
131
+ expect(res.status).toBe(200);
132
+
133
+ const data = JSON.parse(fs.readFileSync(vpsJsonPath, 'utf8'));
134
+ expect(data.servers).toHaveLength(0);
135
+ });
136
+ });
137
+
138
+ describe('POST /vps/servers/:id/test', () => {
139
+ it('tests SSH connection and returns server info', async () => {
140
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
141
+ servers: [{
142
+ id: 'abc', name: 'dev-1', host: '1.2.3.4', port: 22,
143
+ username: 'deploy', privateKeyPath: '~/.ssh/id_rsa',
144
+ }],
145
+ }));
146
+ const res = await request(app).post('/vps/servers/abc/test');
147
+ expect(res.status).toBe(200);
148
+ expect(res.body.connected).toBe(true);
149
+ expect(mockSsh.testConnection).toHaveBeenCalled();
150
+ });
151
+ });
152
+
153
+ describe('POST /vps/servers/:id/assign', () => {
154
+ it('assigns project to server', async () => {
155
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
156
+ servers: [{
157
+ id: 'abc', name: 'dev-1', host: '1.2.3.4', port: 22,
158
+ username: 'deploy', assignedProjects: [],
159
+ }],
160
+ }));
161
+ const res = await request(app).post('/vps/servers/abc/assign').send({ projectId: 'proj1' });
162
+ expect(res.status).toBe(200);
163
+ expect(res.body.assignedProjects).toContain('proj1');
164
+ });
165
+
166
+ it('does not duplicate project assignment', async () => {
167
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
168
+ servers: [{
169
+ id: 'abc', name: 'dev-1', host: '1.2.3.4', port: 22,
170
+ username: 'deploy', assignedProjects: ['proj1'],
171
+ }],
172
+ }));
173
+ const res = await request(app).post('/vps/servers/abc/assign').send({ projectId: 'proj1' });
174
+ expect(res.status).toBe(200);
175
+ expect(res.body.assignedProjects.filter(p => p === 'proj1')).toHaveLength(1);
176
+ });
177
+ });
178
+
179
+ describe('POST /vps/servers/:id/unassign', () => {
180
+ it('removes project from server', async () => {
181
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
182
+ servers: [{
183
+ id: 'abc', name: 'dev-1', host: '1.2.3.4', port: 22,
184
+ username: 'deploy', assignedProjects: ['proj1', 'proj2'],
185
+ }],
186
+ }));
187
+ const res = await request(app).post('/vps/servers/abc/unassign').send({ projectId: 'proj1' });
188
+ expect(res.status).toBe(200);
189
+ expect(res.body.assignedProjects).not.toContain('proj1');
190
+ expect(res.body.assignedProjects).toContain('proj2');
191
+ });
192
+ });
193
+
194
+ describe('GET /vps/pool', () => {
195
+ it('returns only pool servers', async () => {
196
+ fs.writeFileSync(vpsJsonPath, JSON.stringify({
197
+ servers: [
198
+ { id: '1', name: 'shared', host: '1.1.1.1', port: 22, username: 'deploy', pool: true },
199
+ { id: '2', name: 'dedicated', host: '2.2.2.2', port: 22, username: 'deploy', pool: false },
200
+ ],
201
+ }));
202
+ const res = await request(app).get('/vps/pool');
203
+ expect(res.status).toBe(200);
204
+ expect(res.body).toHaveLength(1);
205
+ expect(res.body[0].name).toBe('shared');
206
+ });
207
+ });
208
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * VPS Bootstrap — idempotent server setup via SSH
3
+ * Phase 80 Task 5
4
+ */
5
+
6
+ const { isValidUsername } = require('./input-sanitizer.js');
7
+
8
+ /**
9
+ * Generate idempotent bootstrap bash script
10
+ * @param {Object} options
11
+ * @param {string} [options.deployUser=deploy] - Deploy user to create
12
+ * @returns {string} Bash script
13
+ */
14
+ function generateBootstrapScript(options = {}) {
15
+ const deployUser = options.deployUser || 'deploy';
16
+ if (!isValidUsername(deployUser)) throw new Error(`Invalid deploy user: ${deployUser}`);
17
+
18
+ return `#!/bin/bash
19
+ set -e
20
+
21
+ echo "=== TLC VPS Bootstrap ==="
22
+ echo "Date: $(date)"
23
+
24
+ # Step 1: Update packages
25
+ echo "[1/7] Updating packages..."
26
+ apt-get update -y && apt-get upgrade -y
27
+
28
+ # Step 2: Install Docker (idempotent)
29
+ echo "[2/7] Installing Docker..."
30
+ if command -v docker &>/dev/null; then
31
+ echo " Docker already installed: $(docker --version)"
32
+ else
33
+ curl -fsSL https://get.docker.com | sh
34
+ systemctl enable docker
35
+ systemctl start docker
36
+ echo " Docker installed: $(docker --version)"
37
+ fi
38
+
39
+ # Install Docker Compose plugin
40
+ if docker compose version &>/dev/null; then
41
+ echo " Docker Compose already installed"
42
+ else
43
+ apt-get install -y docker-compose-plugin
44
+ fi
45
+
46
+ # Step 3: Install Nginx (idempotent)
47
+ echo "[3/7] Installing Nginx..."
48
+ if command -v nginx &>/dev/null; then
49
+ echo " Nginx already installed: $(nginx -v 2>&1)"
50
+ else
51
+ apt-get install -y nginx
52
+ systemctl enable nginx
53
+ systemctl start nginx
54
+ echo " Nginx installed"
55
+ fi
56
+
57
+ # Step 4: Install Certbot (idempotent)
58
+ echo "[4/7] Installing Certbot..."
59
+ if command -v certbot &>/dev/null; then
60
+ echo " Certbot already installed: $(certbot --version 2>&1)"
61
+ else
62
+ apt-get install -y certbot python3-certbot-nginx
63
+ echo " Certbot installed"
64
+ fi
65
+
66
+ # Step 5: Configure UFW firewall
67
+ echo "[5/7] Configuring firewall..."
68
+ apt-get install -y ufw
69
+ ufw default deny incoming
70
+ ufw default allow outgoing
71
+ ufw allow 22/tcp
72
+ ufw allow 80/tcp
73
+ ufw allow 443/tcp
74
+ echo "y" | ufw enable
75
+ echo " Firewall configured (22, 80, 443)"
76
+
77
+ # Step 6: Create deploy user (idempotent)
78
+ echo "[6/7] Setting up deploy user..."
79
+ if id "${deployUser}" &>/dev/null; then
80
+ echo " User ${deployUser} already exists"
81
+ else
82
+ useradd -m -s /bin/bash ${deployUser}
83
+ usermod -aG docker ${deployUser}
84
+ mkdir -p /home/${deployUser}/.ssh
85
+ chmod 700 /home/${deployUser}/.ssh
86
+ # Copy authorized keys from root if available
87
+ if [ -f /root/.ssh/authorized_keys ]; then
88
+ cp /root/.ssh/authorized_keys /home/${deployUser}/.ssh/
89
+ chown -R ${deployUser}:${deployUser} /home/${deployUser}/.ssh
90
+ chmod 600 /home/${deployUser}/.ssh/authorized_keys
91
+ fi
92
+ echo " User ${deployUser} created and added to docker group"
93
+ fi
94
+
95
+ # Step 7: Create deployment directories
96
+ echo "[7/7] Setting up deployment directories..."
97
+ mkdir -p /opt/deploys
98
+ chown ${deployUser}:${deployUser} /opt/deploys
99
+
100
+ echo ""
101
+ echo "=== Bootstrap Complete ==="
102
+ echo "Docker: $(docker --version 2>/dev/null || echo 'not installed')"
103
+ echo "Nginx: $(nginx -v 2>&1 || echo 'not installed')"
104
+ echo "Certbot: $(certbot --version 2>&1 || echo 'not installed')"
105
+ echo "UFW: $(ufw status | head -1)"
106
+ echo "User: ${deployUser}"
107
+ `;
108
+ }
109
+
110
+ /**
111
+ * Parse bootstrap status from SSH command outputs
112
+ * @param {Object} output - { docker, nginx, certbot, ufw }
113
+ * @returns {Object} { docker, nginx, certbot, firewall }
114
+ */
115
+ function checkBootstrapStatus(output) {
116
+ return {
117
+ docker: !!(output.docker && !output.docker.includes('not found') && output.docker.includes('version')),
118
+ nginx: !!(output.nginx && !output.nginx.includes('not found') && output.nginx.match(/nginx/i)),
119
+ certbot: !!(output.certbot && !output.certbot.includes('not found') && output.certbot.match(/certbot/i)),
120
+ firewall: !!(output.ufw && output.ufw.includes('active') && !output.ufw.includes('inactive')),
121
+ };
122
+ }
123
+
124
+ module.exports = { generateBootstrapScript, checkBootstrapStatus };
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ const { generateBootstrapScript, checkBootstrapStatus } = await import('./vps-bootstrap.js');
4
+
5
+ describe('VPS Bootstrap', () => {
6
+ describe('generateBootstrapScript', () => {
7
+ it('returns a valid bash script', () => {
8
+ const script = generateBootstrapScript({ deployUser: 'deploy' });
9
+ expect(script).toContain('#!/bin/bash');
10
+ });
11
+
12
+ it('includes Docker install step', () => {
13
+ const script = generateBootstrapScript({});
14
+ expect(script).toContain('docker');
15
+ expect(script).toMatch(/get.docker.com|install.*docker/i);
16
+ });
17
+
18
+ it('includes Nginx install step', () => {
19
+ const script = generateBootstrapScript({});
20
+ expect(script).toContain('nginx');
21
+ expect(script).toMatch(/apt.*install.*nginx|install.*nginx/i);
22
+ });
23
+
24
+ it('includes UFW firewall config (ports 22, 80, 443)', () => {
25
+ const script = generateBootstrapScript({});
26
+ expect(script).toContain('ufw');
27
+ expect(script).toContain('22');
28
+ expect(script).toContain('80');
29
+ expect(script).toContain('443');
30
+ });
31
+
32
+ it('includes Certbot install for SSL', () => {
33
+ const script = generateBootstrapScript({});
34
+ expect(script).toContain('certbot');
35
+ });
36
+
37
+ it('creates deploy user when specified', () => {
38
+ const script = generateBootstrapScript({ deployUser: 'deploy' });
39
+ expect(script).toContain('deploy');
40
+ expect(script).toMatch(/useradd|adduser/);
41
+ });
42
+
43
+ it('is idempotent (checks before installing)', () => {
44
+ const script = generateBootstrapScript({});
45
+ // Should check if Docker already installed
46
+ expect(script).toMatch(/which docker|command -v docker|docker.*--version/);
47
+ });
48
+ });
49
+
50
+ describe('checkBootstrapStatus', () => {
51
+ it('parses installed components from SSH output', () => {
52
+ const output = {
53
+ docker: 'Docker version 24.0.0, build abc123',
54
+ nginx: 'nginx/1.22.1',
55
+ certbot: 'certbot 2.0.0',
56
+ ufw: 'Status: active',
57
+ };
58
+ const status = checkBootstrapStatus(output);
59
+ expect(status.docker).toBe(true);
60
+ expect(status.nginx).toBe(true);
61
+ expect(status.certbot).toBe(true);
62
+ expect(status.firewall).toBe(true);
63
+ });
64
+
65
+ it('detects missing components', () => {
66
+ const output = {
67
+ docker: 'command not found',
68
+ nginx: 'command not found',
69
+ certbot: 'command not found',
70
+ ufw: 'Status: inactive',
71
+ };
72
+ const status = checkBootstrapStatus(output);
73
+ expect(status.docker).toBe(false);
74
+ expect(status.nginx).toBe(false);
75
+ expect(status.certbot).toBe(false);
76
+ expect(status.firewall).toBe(false);
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * VPS Monitor — server metrics collection and alerting
3
+ * Phase 80 Task 9
4
+ */
5
+
6
+ const { isValidDomain } = require('./input-sanitizer.js');
7
+
8
+ /**
9
+ * Create VPS monitor
10
+ * @param {Object} options
11
+ * @param {Object} options.sshClient - SSH client instance
12
+ * @returns {Object} VPS monitor API
13
+ */
14
+ function createVpsMonitor({ sshClient }) {
15
+
16
+ /**
17
+ * Collect server metrics via SSH
18
+ * @param {Object} sshConfig
19
+ * @returns {Promise<Object>} metrics
20
+ */
21
+ async function getServerMetrics(sshConfig) {
22
+ const [dfResult, freeResult, cpuResult, uptimeResult, dockerResult] = await Promise.all([
23
+ sshClient.exec(sshConfig, "df -h / | tail -1"),
24
+ sshClient.exec(sshConfig, "free -k | head -2"),
25
+ sshClient.exec(sshConfig, "cat /proc/stat | head -1"),
26
+ sshClient.exec(sshConfig, "uptime"),
27
+ sshClient.exec(sshConfig, "docker ps --format json 2>/dev/null || echo '[]'"),
28
+ ]);
29
+
30
+ // Parse disk
31
+ const dfParts = dfResult.stdout.trim().split(/\s+/);
32
+ const diskPercent = parseInt((dfParts[4] || '0').replace('%', ''), 10);
33
+
34
+ // Parse memory
35
+ const memLines = freeResult.stdout.trim().split('\n');
36
+ const memParts = (memLines[1] || '').trim().split(/\s+/);
37
+ const totalKb = parseInt(memParts[1] || '0', 10);
38
+ const usedKb = parseInt(memParts[2] || '0', 10);
39
+
40
+ // Parse CPU
41
+ const cpuParts = cpuResult.stdout.trim().split(/\s+/).slice(1).map(Number);
42
+ const cpuTotal = cpuParts.reduce((a, b) => a + b, 0);
43
+ const cpuIdle = cpuParts[3] || 0;
44
+ const cpuPercent = cpuTotal > 0 ? Math.round(((cpuTotal - cpuIdle) / cpuTotal) * 100) : 0;
45
+
46
+ // Parse containers
47
+ let containers = [];
48
+ try {
49
+ const dockerOut = dockerResult.stdout.trim();
50
+ if (dockerOut.startsWith('[')) {
51
+ containers = JSON.parse(dockerOut);
52
+ } else if (dockerOut.startsWith('{')) {
53
+ containers = dockerOut.split('\n').filter(Boolean).map(l => JSON.parse(l));
54
+ }
55
+ } catch {}
56
+
57
+ return {
58
+ disk: { usedPercent: diskPercent, raw: dfResult.stdout.trim() },
59
+ memory: { totalKb, usedKb, usedPercent: totalKb > 0 ? Math.round((usedKb / totalKb) * 100) : 0 },
60
+ cpu: { usedPercent: cpuPercent },
61
+ uptime: uptimeResult.stdout.trim(),
62
+ containers: containers.map(c => ({
63
+ name: c.Names || c.name || '',
64
+ state: c.State || c.state || 'unknown',
65
+ status: c.Status || c.status || '',
66
+ })),
67
+ timestamp: new Date().toISOString(),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Check for alert conditions
73
+ * @param {Object} metrics
74
+ * @returns {Array} alerts
75
+ */
76
+ function checkAlerts(metrics) {
77
+ const alerts = [];
78
+
79
+ // Disk alerts
80
+ if (metrics.disk && metrics.disk.usedPercent > 90) {
81
+ alerts.push({ type: 'disk', level: 'critical', message: `Disk ${metrics.disk.usedPercent}% full` });
82
+ } else if (metrics.disk && metrics.disk.usedPercent > 80) {
83
+ alerts.push({ type: 'disk', level: 'warning', message: `Disk ${metrics.disk.usedPercent}% full` });
84
+ }
85
+
86
+ // Memory alerts
87
+ if (metrics.memory && metrics.memory.usedPercent > 90) {
88
+ alerts.push({ type: 'memory', level: 'warning', message: `Memory ${metrics.memory.usedPercent}% used` });
89
+ }
90
+
91
+ // Container alerts
92
+ if (metrics.containers) {
93
+ for (const c of metrics.containers) {
94
+ if (c.state === 'exited' && c.exitCode !== undefined && c.exitCode !== 0) {
95
+ alerts.push({ type: 'container', level: 'critical', message: `Container ${c.name} crashed (exit code ${c.exitCode})` });
96
+ }
97
+ }
98
+ }
99
+
100
+ return alerts;
101
+ }
102
+
103
+ /**
104
+ * Check SSL certificate expiry
105
+ * @param {Object} sshConfig
106
+ * @param {string} domain
107
+ * @returns {Promise<Object>}
108
+ */
109
+ async function checkSslExpiry(sshConfig, domain) {
110
+ if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain}`);
111
+ const result = await sshClient.exec(
112
+ sshConfig,
113
+ `openssl x509 -enddate -noout -in /etc/letsencrypt/live/${domain}/fullchain.pem 2>/dev/null || echo "not found"`
114
+ );
115
+
116
+ const match = result.stdout.match(/notAfter=(.+)/);
117
+ const expiresAt = match ? match[1].trim() : null;
118
+ const daysLeft = expiresAt ? Math.floor((new Date(expiresAt) - new Date()) / 86400000) : null;
119
+
120
+ return { domain, expiresAt, daysLeft, warning: daysLeft !== null && daysLeft < 14 };
121
+ }
122
+
123
+ return { getServerMetrics, checkAlerts, checkSslExpiry };
124
+ }
125
+
126
+ module.exports = { createVpsMonitor };
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ const { createVpsMonitor } = await import('./vps-monitor.js');
4
+
5
+ function createMockSsh() {
6
+ return {
7
+ exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
8
+ };
9
+ }
10
+
11
+ describe('VPS Monitor', () => {
12
+ let monitor;
13
+ let mockSsh;
14
+
15
+ beforeEach(() => {
16
+ mockSsh = createMockSsh();
17
+ monitor = createVpsMonitor({ sshClient: mockSsh });
18
+ });
19
+
20
+ describe('getServerMetrics', () => {
21
+ it('parses disk usage from df output', async () => {
22
+ mockSsh.exec.mockImplementation(async (config, cmd) => {
23
+ if (cmd.includes('df')) return { stdout: '/dev/sda1 50G 20G 28G 42% /', stderr: '', exitCode: 0 };
24
+ if (cmd.includes('free')) return { stdout: ' total used free\nMem: 8000000 4000000 3000000', stderr: '', exitCode: 0 };
25
+ if (cmd.includes('nproc')) return { stdout: '4', stderr: '', exitCode: 0 };
26
+ if (cmd.includes('/proc/stat')) return { stdout: 'cpu 1000 200 300 7000 100 0 0 0', stderr: '', exitCode: 0 };
27
+ if (cmd.includes('uptime')) return { stdout: ' 12:00:00 up 30 days', stderr: '', exitCode: 0 };
28
+ if (cmd.includes('docker')) return { stdout: '[]', stderr: '', exitCode: 0 };
29
+ return { stdout: '', stderr: '', exitCode: 0 };
30
+ });
31
+
32
+ const metrics = await monitor.getServerMetrics({ host: '1.2.3.4', username: 'deploy' });
33
+ expect(metrics.disk).toBeDefined();
34
+ expect(metrics.disk.usedPercent).toBe(42);
35
+ });
36
+
37
+ it('parses memory from free output', async () => {
38
+ mockSsh.exec.mockImplementation(async (config, cmd) => {
39
+ if (cmd.includes('df')) return { stdout: '/dev/sda1 50G 20G 28G 42% /', stderr: '', exitCode: 0 };
40
+ if (cmd.includes('free')) return { stdout: ' total used free\nMem: 8000000 4000000 3000000', stderr: '', exitCode: 0 };
41
+ if (cmd.includes('nproc')) return { stdout: '4', stderr: '', exitCode: 0 };
42
+ if (cmd.includes('/proc/stat')) return { stdout: 'cpu 1000 200 300 7000 100 0 0 0', stderr: '', exitCode: 0 };
43
+ if (cmd.includes('uptime')) return { stdout: ' 12:00:00 up 30 days', stderr: '', exitCode: 0 };
44
+ if (cmd.includes('docker')) return { stdout: '[]', stderr: '', exitCode: 0 };
45
+ return { stdout: '', stderr: '', exitCode: 0 };
46
+ });
47
+
48
+ const metrics = await monitor.getServerMetrics({ host: '1.2.3.4', username: 'deploy' });
49
+ expect(metrics.memory).toBeDefined();
50
+ expect(metrics.memory.totalKb).toBe(8000000);
51
+ expect(metrics.memory.usedKb).toBe(4000000);
52
+ });
53
+ });
54
+
55
+ describe('checkAlerts', () => {
56
+ it('returns warning for disk > 80%', () => {
57
+ const alerts = monitor.checkAlerts({ disk: { usedPercent: 85 }, memory: { usedPercent: 50 }, containers: [] });
58
+ expect(alerts.some(a => a.level === 'warning' && a.type === 'disk')).toBe(true);
59
+ });
60
+
61
+ it('returns critical for disk > 90%', () => {
62
+ const alerts = monitor.checkAlerts({ disk: { usedPercent: 95 }, memory: { usedPercent: 50 }, containers: [] });
63
+ expect(alerts.some(a => a.level === 'critical' && a.type === 'disk')).toBe(true);
64
+ });
65
+
66
+ it('returns alert for crashed container', () => {
67
+ const alerts = monitor.checkAlerts({
68
+ disk: { usedPercent: 30 },
69
+ memory: { usedPercent: 50 },
70
+ containers: [{ name: 'myapp', state: 'exited', exitCode: 1 }],
71
+ });
72
+ expect(alerts.some(a => a.type === 'container')).toBe(true);
73
+ });
74
+
75
+ it('returns no alerts when everything is healthy', () => {
76
+ const alerts = monitor.checkAlerts({
77
+ disk: { usedPercent: 30 },
78
+ memory: { usedPercent: 50 },
79
+ containers: [{ name: 'myapp', state: 'running', exitCode: 0 }],
80
+ });
81
+ expect(alerts).toHaveLength(0);
82
+ });
83
+ });
84
+
85
+ describe('checkSslExpiry', () => {
86
+ it('parses cert expiry date', async () => {
87
+ mockSsh.exec.mockResolvedValue({
88
+ stdout: 'notAfter=Mar 15 00:00:00 2026 GMT',
89
+ stderr: '',
90
+ exitCode: 0,
91
+ });
92
+
93
+ const expiry = await monitor.checkSslExpiry({ host: '1.2.3.4', username: 'deploy' }, 'myapp.dev');
94
+ expect(expiry.domain).toBe('myapp.dev');
95
+ expect(expiry.expiresAt).toBeTruthy();
96
+ });
97
+ });
98
+ });