tlc-claude-code 2.0.1 → 2.2.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/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +4 -0
- 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-plan.md +363 -0
- package/.claude/commands/tlc/review.md +172 -57
- 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 +13 -0
- package/bin/install.js +268 -2
- package/bin/postinstall.js +102 -24
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- 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 +4 -2
- package/scripts/project-docs.js +1 -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/cost-tracker.test.js +49 -12
- 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/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -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/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -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/server/setup.sh +271 -271
- 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,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
|
+
});
|
|
@@ -16,6 +16,8 @@ const { createTestInventory } = require('./test-inventory');
|
|
|
16
16
|
const { createRoadmapApi } = require('./roadmap-api');
|
|
17
17
|
const { createPlanWriter } = require('./plan-writer');
|
|
18
18
|
const { createBugWriter } = require('./bug-writer');
|
|
19
|
+
const { createMemoryStoreAdapter } = require('./memory-store-adapter');
|
|
20
|
+
const { createCaptureGuard } = require('./capture-guard');
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Encode a project path to a URL-safe project ID
|
|
@@ -62,6 +64,7 @@ function readProjectStatus(projectPath) {
|
|
|
62
64
|
phaseName: null,
|
|
63
65
|
totalPhases: 0,
|
|
64
66
|
completedPhases: 0,
|
|
67
|
+
coverage: null,
|
|
65
68
|
};
|
|
66
69
|
|
|
67
70
|
if (!status.exists) {
|
|
@@ -128,6 +131,19 @@ function readProjectStatus(projectPath) {
|
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
// Read coverage from Istanbul coverage-summary.json
|
|
135
|
+
const coveragePath = path.join(projectPath, 'coverage', 'coverage-summary.json');
|
|
136
|
+
if (fs.existsSync(coveragePath)) {
|
|
137
|
+
try {
|
|
138
|
+
const covData = JSON.parse(fs.readFileSync(coveragePath, 'utf-8'));
|
|
139
|
+
if (covData.total && covData.total.lines && typeof covData.total.lines.pct === 'number') {
|
|
140
|
+
status.coverage = covData.total.lines.pct;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Malformed coverage file — leave as null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
131
147
|
return status;
|
|
132
148
|
}
|
|
133
149
|
|
|
@@ -292,7 +308,7 @@ function readProjectBugs(projectPath) {
|
|
|
292
308
|
* @returns {express.Router} Express router with workspace endpoints
|
|
293
309
|
*/
|
|
294
310
|
function createWorkspaceRouter(options = {}) {
|
|
295
|
-
const { globalConfig, projectScanner } = options;
|
|
311
|
+
const { globalConfig, projectScanner, memoryApi, memoryDeps = {} } = options;
|
|
296
312
|
|
|
297
313
|
if (!globalConfig) {
|
|
298
314
|
throw new Error('globalConfig is required');
|
|
@@ -302,6 +318,7 @@ function createWorkspaceRouter(options = {}) {
|
|
|
302
318
|
}
|
|
303
319
|
|
|
304
320
|
const router = express.Router();
|
|
321
|
+
const captureGuard = createCaptureGuard();
|
|
305
322
|
|
|
306
323
|
// =========================================================================
|
|
307
324
|
// GET /config - Returns workspace configuration
|
|
@@ -805,6 +822,170 @@ function createWorkspaceRouter(options = {}) {
|
|
|
805
822
|
}
|
|
806
823
|
});
|
|
807
824
|
|
|
825
|
+
// =========================================================================
|
|
826
|
+
// Memory API routes (Phase 77, fixed per-project in Phase 78)
|
|
827
|
+
// =========================================================================
|
|
828
|
+
if (memoryApi) {
|
|
829
|
+
router.get('/projects/:projectId/memory/decisions', async (req, res) => {
|
|
830
|
+
try {
|
|
831
|
+
const roots = globalConfig.getRoots();
|
|
832
|
+
const project = findProjectById(projectScanner, roots, req.params.projectId);
|
|
833
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
834
|
+
const adapter = createMemoryStoreAdapter(project.path);
|
|
835
|
+
const decisions = await adapter.listDecisions();
|
|
836
|
+
res.json({ decisions });
|
|
837
|
+
} catch (err) {
|
|
838
|
+
res.status(500).json({ error: err.message });
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
router.get('/projects/:projectId/memory/gotchas', async (req, res) => {
|
|
843
|
+
try {
|
|
844
|
+
const roots = globalConfig.getRoots();
|
|
845
|
+
const project = findProjectById(projectScanner, roots, req.params.projectId);
|
|
846
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
847
|
+
const adapter = createMemoryStoreAdapter(project.path);
|
|
848
|
+
const gotchas = await adapter.listGotchas();
|
|
849
|
+
res.json({ gotchas });
|
|
850
|
+
} catch (err) {
|
|
851
|
+
res.status(500).json({ error: err.message });
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
router.get('/projects/:projectId/memory/stats', async (req, res) => {
|
|
856
|
+
try {
|
|
857
|
+
const roots = globalConfig.getRoots();
|
|
858
|
+
const project = findProjectById(projectScanner, roots, req.params.projectId);
|
|
859
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
860
|
+
const adapter = createMemoryStoreAdapter(project.path);
|
|
861
|
+
const stats = await adapter.getStats();
|
|
862
|
+
res.json(stats);
|
|
863
|
+
} catch (err) {
|
|
864
|
+
res.status(500).json({ error: err.message });
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// =========================================================================
|
|
870
|
+
// Memory capture endpoint (Phase 79 Task 5)
|
|
871
|
+
// =========================================================================
|
|
872
|
+
router.post('/projects/:projectId/memory/capture', async (req, res) => {
|
|
873
|
+
try {
|
|
874
|
+
const projectId = req.params.projectId;
|
|
875
|
+
|
|
876
|
+
// Rate limit check
|
|
877
|
+
const rateCheck = captureGuard.checkRateLimit(projectId);
|
|
878
|
+
if (!rateCheck.ok) {
|
|
879
|
+
return res.status(rateCheck.status).json({ error: rateCheck.error });
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Payload validation (size + structure)
|
|
883
|
+
const validation = captureGuard.validate(req.body, projectId);
|
|
884
|
+
if (!validation.ok) {
|
|
885
|
+
return res.status(validation.status).json({ error: validation.error });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const roots = globalConfig.getRoots();
|
|
889
|
+
const project = findProjectById(projectScanner, roots, projectId);
|
|
890
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
891
|
+
|
|
892
|
+
// Deduplicate exchanges
|
|
893
|
+
const exchanges = captureGuard.deduplicate(req.body.exchanges, projectId);
|
|
894
|
+
|
|
895
|
+
if (exchanges.length === 0) {
|
|
896
|
+
return res.json({ captured: 0, deduplicated: true });
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Process in background — respond immediately
|
|
900
|
+
let captured = 0;
|
|
901
|
+
const { observeAndRemember, vectorIndexer } = memoryDeps;
|
|
902
|
+
|
|
903
|
+
for (const exchange of exchanges) {
|
|
904
|
+
try {
|
|
905
|
+
if (typeof observeAndRemember === 'function') {
|
|
906
|
+
await observeAndRemember(project.path, exchange);
|
|
907
|
+
}
|
|
908
|
+
if (vectorIndexer && typeof vectorIndexer.indexChunk === 'function') {
|
|
909
|
+
await vectorIndexer.indexChunk(exchange);
|
|
910
|
+
}
|
|
911
|
+
captured++;
|
|
912
|
+
} catch {
|
|
913
|
+
// Individual exchange failures don't stop the batch
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
res.json({ captured });
|
|
918
|
+
} catch (err) {
|
|
919
|
+
res.status(500).json({ error: err.message });
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// =========================================================================
|
|
924
|
+
// Memory search endpoint (Phase 79 Task 6)
|
|
925
|
+
// =========================================================================
|
|
926
|
+
router.get('/projects/:projectId/memory/search', async (req, res) => {
|
|
927
|
+
try {
|
|
928
|
+
const roots = globalConfig.getRoots();
|
|
929
|
+
const project = findProjectById(projectScanner, roots, req.params.projectId);
|
|
930
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
931
|
+
|
|
932
|
+
const query = req.query.q;
|
|
933
|
+
if (!query) {
|
|
934
|
+
return res.status(400).json({ error: 'Query parameter q is required' });
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const { semanticRecall } = memoryDeps;
|
|
938
|
+
|
|
939
|
+
// Try vector-based semantic recall first
|
|
940
|
+
if (semanticRecall && typeof semanticRecall.recall === 'function') {
|
|
941
|
+
try {
|
|
942
|
+
const results = await semanticRecall.recall(query, { projectRoot: project.path });
|
|
943
|
+
return res.json({ results: results || [], source: 'vector' });
|
|
944
|
+
} catch {
|
|
945
|
+
// Fall through to file-based search
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Fallback: file-based text search
|
|
950
|
+
try {
|
|
951
|
+
const { searchMemory } = require('./memory-reader');
|
|
952
|
+
const results = await searchMemory(project.path, query);
|
|
953
|
+
return res.json({ results: results || [], source: 'file' });
|
|
954
|
+
} catch {
|
|
955
|
+
return res.json({ results: [], source: 'file' });
|
|
956
|
+
}
|
|
957
|
+
} catch (err) {
|
|
958
|
+
res.status(500).json({ error: err.message });
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
// =========================================================================
|
|
963
|
+
// Project file endpoint (Phase 77)
|
|
964
|
+
// =========================================================================
|
|
965
|
+
router.get('/projects/:projectId/files/:filename', (req, res) => {
|
|
966
|
+
try {
|
|
967
|
+
const roots = globalConfig.getRoots();
|
|
968
|
+
const project = findProjectById(projectScanner, roots, req.params.projectId);
|
|
969
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
970
|
+
|
|
971
|
+
const filename = req.params.filename;
|
|
972
|
+
// Reject path traversal
|
|
973
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
974
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const filePath = path.join(project.path, '.planning', filename);
|
|
978
|
+
if (!fs.existsSync(filePath)) {
|
|
979
|
+
return res.status(404).json({ error: 'File not found' });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
983
|
+
res.json({ filename, content });
|
|
984
|
+
} catch (err) {
|
|
985
|
+
res.status(500).json({ error: err.message });
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
808
989
|
return router;
|
|
809
990
|
}
|
|
810
991
|
|