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.
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +12 -0
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- 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
|
+
});
|