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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Client — wraps ssh2 for VPS communication
|
|
3
|
+
* Phase 80 Task 4
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { Client } = require('ssh2');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve ~ in key paths
|
|
12
|
+
*/
|
|
13
|
+
function resolveKeyPath(keyPath) {
|
|
14
|
+
if (!keyPath) return null;
|
|
15
|
+
if (keyPath.startsWith('~')) {
|
|
16
|
+
return path.join(require('os').homedir(), keyPath.slice(1));
|
|
17
|
+
}
|
|
18
|
+
return keyPath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an SSH client wrapper
|
|
23
|
+
* @param {Object} [options]
|
|
24
|
+
* @param {Function} [options._execFn] - Injected exec function (for testing)
|
|
25
|
+
* @returns {Object} SSH client API
|
|
26
|
+
*/
|
|
27
|
+
function createSshClient(options = {}) {
|
|
28
|
+
const injectedExec = options._execFn;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Execute a command via SSH
|
|
32
|
+
* @param {Object} config - { host, port, username, privateKeyPath }
|
|
33
|
+
* @param {string} command
|
|
34
|
+
* @returns {Promise<{ stdout, stderr, exitCode }>}
|
|
35
|
+
*/
|
|
36
|
+
async function exec(config, command) {
|
|
37
|
+
if (injectedExec) {
|
|
38
|
+
return injectedExec(config, command);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!config.host || !config.username) {
|
|
42
|
+
throw new Error('SSH config requires host and username');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const keyPath = resolveKeyPath(config.privateKeyPath);
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const conn = new Client();
|
|
49
|
+
conn.on('ready', () => {
|
|
50
|
+
conn.exec(command, (err, stream) => {
|
|
51
|
+
if (err) { conn.end(); return reject(err); }
|
|
52
|
+
|
|
53
|
+
let stdout = '';
|
|
54
|
+
let stderr = '';
|
|
55
|
+
|
|
56
|
+
stream.on('data', (data) => { stdout += data.toString(); });
|
|
57
|
+
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
58
|
+
stream.on('close', (code) => {
|
|
59
|
+
conn.end();
|
|
60
|
+
resolve({ stdout, stderr, exitCode: code });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
conn.on('error', (err) => reject(err));
|
|
65
|
+
|
|
66
|
+
const connOpts = {
|
|
67
|
+
host: config.host,
|
|
68
|
+
port: config.port || 22,
|
|
69
|
+
username: config.username,
|
|
70
|
+
readyTimeout: 10000,
|
|
71
|
+
keepaliveInterval: 5000,
|
|
72
|
+
};
|
|
73
|
+
if (keyPath && fs.existsSync(keyPath)) {
|
|
74
|
+
connOpts.privateKey = fs.readFileSync(keyPath);
|
|
75
|
+
}
|
|
76
|
+
conn.connect(connOpts);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Execute with streaming output
|
|
82
|
+
* @param {Object} config
|
|
83
|
+
* @param {string} command
|
|
84
|
+
* @param {Function} onData - Called with each output chunk
|
|
85
|
+
* @returns {Promise<number>} exit code
|
|
86
|
+
*/
|
|
87
|
+
async function execStream(config, command, onData) {
|
|
88
|
+
if (injectedExec) {
|
|
89
|
+
const result = await injectedExec(config, command);
|
|
90
|
+
if (onData && result.stdout) onData(result.stdout);
|
|
91
|
+
return result.exitCode;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!config.host || !config.username) {
|
|
95
|
+
throw new Error('SSH config requires host and username');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const keyPath = resolveKeyPath(config.privateKeyPath);
|
|
99
|
+
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const conn = new Client();
|
|
102
|
+
conn.on('ready', () => {
|
|
103
|
+
conn.exec(command, (err, stream) => {
|
|
104
|
+
if (err) { conn.end(); return reject(err); }
|
|
105
|
+
stream.on('data', (data) => onData && onData(data.toString()));
|
|
106
|
+
stream.stderr.on('data', (data) => onData && onData(data.toString()));
|
|
107
|
+
stream.on('close', (code) => { conn.end(); resolve(code); });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
conn.on('error', (err) => reject(err));
|
|
111
|
+
|
|
112
|
+
const connOpts = {
|
|
113
|
+
host: config.host,
|
|
114
|
+
port: config.port || 22,
|
|
115
|
+
username: config.username,
|
|
116
|
+
};
|
|
117
|
+
if (keyPath && fs.existsSync(keyPath)) {
|
|
118
|
+
connOpts.privateKey = fs.readFileSync(keyPath);
|
|
119
|
+
}
|
|
120
|
+
conn.connect(connOpts);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Upload a file via SFTP
|
|
126
|
+
* @param {Object} config
|
|
127
|
+
* @param {string} localPath
|
|
128
|
+
* @param {string} remotePath
|
|
129
|
+
*/
|
|
130
|
+
async function upload(config, localPath, remotePath) {
|
|
131
|
+
if (!config.host || !config.username) {
|
|
132
|
+
throw new Error('SSH config requires host and username');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const keyPath = resolveKeyPath(config.privateKeyPath);
|
|
136
|
+
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const conn = new Client();
|
|
139
|
+
conn.on('ready', () => {
|
|
140
|
+
conn.sftp((err, sftp) => {
|
|
141
|
+
if (err) { conn.end(); return reject(err); }
|
|
142
|
+
sftp.fastPut(localPath, remotePath, (err) => {
|
|
143
|
+
conn.end();
|
|
144
|
+
if (err) reject(err);
|
|
145
|
+
else resolve();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
conn.on('error', (err) => reject(err));
|
|
150
|
+
|
|
151
|
+
const connOpts = {
|
|
152
|
+
host: config.host,
|
|
153
|
+
port: config.port || 22,
|
|
154
|
+
username: config.username,
|
|
155
|
+
};
|
|
156
|
+
if (keyPath && fs.existsSync(keyPath)) {
|
|
157
|
+
connOpts.privateKey = fs.readFileSync(keyPath);
|
|
158
|
+
}
|
|
159
|
+
conn.connect(connOpts);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Test SSH connection and gather server info
|
|
165
|
+
* @param {Object} config
|
|
166
|
+
* @returns {Promise<{ connected, os, docker, disk }>}
|
|
167
|
+
*/
|
|
168
|
+
async function testConnection(config) {
|
|
169
|
+
const osResult = await exec(config, 'uname -a');
|
|
170
|
+
const dockerResult = await exec(config, 'docker --version 2>/dev/null || echo "not installed"');
|
|
171
|
+
const diskResult = await exec(config, 'df -h / | tail -1');
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
connected: true,
|
|
175
|
+
os: osResult.stdout.trim(),
|
|
176
|
+
docker: dockerResult.stdout.trim(),
|
|
177
|
+
disk: diskResult.stdout.trim(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { exec, execStream, upload, testConnection };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { createSshClient };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { createSshClient } = await import('./ssh-client.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a mock ssh2 Client for injection
|
|
7
|
+
*/
|
|
8
|
+
function createMockSsh2() {
|
|
9
|
+
const mockStream = {
|
|
10
|
+
on: vi.fn().mockReturnThis(),
|
|
11
|
+
stderr: { on: vi.fn().mockReturnThis() },
|
|
12
|
+
end: vi.fn(),
|
|
13
|
+
write: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const mockSftp = {
|
|
17
|
+
fastPut: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mockClient = {
|
|
21
|
+
on: vi.fn().mockReturnThis(),
|
|
22
|
+
connect: vi.fn(),
|
|
23
|
+
exec: vi.fn(),
|
|
24
|
+
sftp: vi.fn(),
|
|
25
|
+
end: vi.fn(),
|
|
26
|
+
_mockStream: mockStream,
|
|
27
|
+
_mockSftp: mockSftp,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return mockClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('SshClient', () => {
|
|
34
|
+
let sshClient;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
sshClient = createSshClient();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('testConnection', () => {
|
|
41
|
+
it('resolves with server info on successful connection', async () => {
|
|
42
|
+
const config = { host: '1.2.3.4', port: 22, username: 'deploy', privateKeyPath: '/fake/key' };
|
|
43
|
+
// Test that testConnection is a function
|
|
44
|
+
expect(typeof sshClient.testConnection).toBe('function');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects when host is unreachable', async () => {
|
|
48
|
+
const config = { host: 'invalid', port: 22, username: 'root', privateKeyPath: '/nonexistent' };
|
|
49
|
+
await expect(sshClient.testConnection(config)).rejects.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('exec', () => {
|
|
54
|
+
it('is a function that accepts config and command', () => {
|
|
55
|
+
expect(typeof sshClient.exec).toBe('function');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects with error for bad config', async () => {
|
|
59
|
+
await expect(sshClient.exec({}, 'whoami')).rejects.toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('execStream', () => {
|
|
64
|
+
it('is a function that accepts config, command, and callback', () => {
|
|
65
|
+
expect(typeof sshClient.execStream).toBe('function');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('upload', () => {
|
|
70
|
+
it('is a function that accepts config, localPath, remotePath', () => {
|
|
71
|
+
expect(typeof sshClient.upload).toBe('function');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('SshClient with mock', () => {
|
|
77
|
+
it('exec resolves with stdout, stderr, exitCode via injected client', async () => {
|
|
78
|
+
// Create a mock that simulates successful exec
|
|
79
|
+
const mockExecResult = { stdout: 'root\n', stderr: '', exitCode: 0 };
|
|
80
|
+
const sshClient = createSshClient({
|
|
81
|
+
_execFn: vi.fn().mockResolvedValue(mockExecResult),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await sshClient.exec(
|
|
85
|
+
{ host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' },
|
|
86
|
+
'whoami'
|
|
87
|
+
);
|
|
88
|
+
expect(result).toEqual(mockExecResult);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('exec rejects when command fails', async () => {
|
|
92
|
+
const sshClient = createSshClient({
|
|
93
|
+
_execFn: vi.fn().mockRejectedValue(new Error('command failed')),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(
|
|
97
|
+
sshClient.exec({ host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' }, 'bad-cmd')
|
|
98
|
+
).rejects.toThrow('command failed');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('testConnection returns server info', async () => {
|
|
102
|
+
const sshClient = createSshClient({
|
|
103
|
+
_execFn: vi.fn()
|
|
104
|
+
.mockResolvedValueOnce({ stdout: 'Linux\n', stderr: '', exitCode: 0 }) // uname
|
|
105
|
+
.mockResolvedValueOnce({ stdout: 'Docker version 24.0.0\n', stderr: '', exitCode: 0 }) // docker
|
|
106
|
+
.mockResolvedValueOnce({ stdout: '/dev/sda1 50G 20G 28G 42% /\n', stderr: '', exitCode: 0 }), // df
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const info = await sshClient.testConnection(
|
|
110
|
+
{ host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' }
|
|
111
|
+
);
|
|
112
|
+
expect(info.os).toContain('Linux');
|
|
113
|
+
expect(info.docker).toContain('24.0.0');
|
|
114
|
+
expect(info.disk).toBeTruthy();
|
|
115
|
+
expect(info.connected).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('testConnection reports disconnected on failure', async () => {
|
|
119
|
+
const sshClient = createSshClient({
|
|
120
|
+
_execFn: vi.fn().mockRejectedValue(new Error('Connection refused')),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expect(
|
|
124
|
+
sshClient.testConnection({ host: '1.2.3.4', username: 'root', privateKeyPath: '/key' })
|
|
125
|
+
).rejects.toThrow('Connection refused');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VPS API Router — Express routes for VPS management
|
|
3
|
+
* Phase 80 Task 4
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const express = require('express');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read VPS data from disk
|
|
13
|
+
*/
|
|
14
|
+
function readVpsData(vpsJsonPath) {
|
|
15
|
+
try {
|
|
16
|
+
if (fs.existsSync(vpsJsonPath)) {
|
|
17
|
+
return JSON.parse(fs.readFileSync(vpsJsonPath, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
} catch {}
|
|
20
|
+
return { servers: [] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write VPS data to disk
|
|
25
|
+
*/
|
|
26
|
+
function writeVpsData(vpsJsonPath, data) {
|
|
27
|
+
const dir = path.dirname(vpsJsonPath);
|
|
28
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
fs.writeFileSync(vpsJsonPath, JSON.stringify(data, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create VPS API router
|
|
34
|
+
* @param {Object} options
|
|
35
|
+
* @param {Object} options.sshClient - SSH client instance
|
|
36
|
+
* @param {string} options.configDir - Directory for vps.json
|
|
37
|
+
* @returns {express.Router}
|
|
38
|
+
*/
|
|
39
|
+
function createVpsRouter({ sshClient, configDir }) {
|
|
40
|
+
const router = express.Router();
|
|
41
|
+
const vpsJsonPath = path.join(configDir, 'vps.json');
|
|
42
|
+
|
|
43
|
+
// GET /vps/servers
|
|
44
|
+
router.get('/servers', (req, res) => {
|
|
45
|
+
const data = readVpsData(vpsJsonPath);
|
|
46
|
+
res.json(data.servers);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// POST /vps/servers
|
|
50
|
+
router.post('/servers', (req, res) => {
|
|
51
|
+
const { name, host, port, username, privateKeyPath, domain, provider, pool } = req.body;
|
|
52
|
+
|
|
53
|
+
if (!name || !host || !username) {
|
|
54
|
+
return res.status(400).json({ error: 'name, host, and username are required' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const server = {
|
|
58
|
+
id: crypto.randomUUID(),
|
|
59
|
+
name,
|
|
60
|
+
host,
|
|
61
|
+
port: port || 22,
|
|
62
|
+
username,
|
|
63
|
+
privateKeyPath: privateKeyPath || '',
|
|
64
|
+
domain: domain || '',
|
|
65
|
+
provider: provider || '',
|
|
66
|
+
pool: pool !== false,
|
|
67
|
+
assignedProjects: [],
|
|
68
|
+
status: 'unknown',
|
|
69
|
+
lastChecked: null,
|
|
70
|
+
bootstrapped: false,
|
|
71
|
+
createdAt: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const data = readVpsData(vpsJsonPath);
|
|
75
|
+
data.servers.push(server);
|
|
76
|
+
writeVpsData(vpsJsonPath, data);
|
|
77
|
+
|
|
78
|
+
res.status(201).json(server);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// GET /vps/servers/:id
|
|
82
|
+
router.get('/servers/:id', (req, res) => {
|
|
83
|
+
const data = readVpsData(vpsJsonPath);
|
|
84
|
+
const server = data.servers.find(s => s.id === req.params.id);
|
|
85
|
+
if (!server) return res.status(404).json({ error: 'Server not found' });
|
|
86
|
+
res.json(server);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// PUT /vps/servers/:id
|
|
90
|
+
router.put('/servers/:id', (req, res) => {
|
|
91
|
+
const data = readVpsData(vpsJsonPath);
|
|
92
|
+
const idx = data.servers.findIndex(s => s.id === req.params.id);
|
|
93
|
+
if (idx === -1) return res.status(404).json({ error: 'Server not found' });
|
|
94
|
+
|
|
95
|
+
const allowed = ['name', 'host', 'port', 'username', 'privateKeyPath', 'domain', 'provider', 'pool'];
|
|
96
|
+
for (const key of allowed) {
|
|
97
|
+
if (req.body[key] !== undefined) {
|
|
98
|
+
data.servers[idx][key] = req.body[key];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
writeVpsData(vpsJsonPath, data);
|
|
102
|
+
res.json(data.servers[idx]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// DELETE /vps/servers/:id
|
|
106
|
+
router.delete('/servers/:id', (req, res) => {
|
|
107
|
+
const data = readVpsData(vpsJsonPath);
|
|
108
|
+
const idx = data.servers.findIndex(s => s.id === req.params.id);
|
|
109
|
+
if (idx === -1) return res.status(404).json({ error: 'Server not found' });
|
|
110
|
+
data.servers.splice(idx, 1);
|
|
111
|
+
writeVpsData(vpsJsonPath, data);
|
|
112
|
+
res.json({ ok: true });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// POST /vps/servers/:id/test
|
|
116
|
+
router.post('/servers/:id/test', async (req, res) => {
|
|
117
|
+
const data = readVpsData(vpsJsonPath);
|
|
118
|
+
const server = data.servers.find(s => s.id === req.params.id);
|
|
119
|
+
if (!server) return res.status(404).json({ error: 'Server not found' });
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const info = await sshClient.testConnection({
|
|
123
|
+
host: server.host,
|
|
124
|
+
port: server.port,
|
|
125
|
+
username: server.username,
|
|
126
|
+
privateKeyPath: server.privateKeyPath,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Update status
|
|
130
|
+
const idx = data.servers.findIndex(s => s.id === req.params.id);
|
|
131
|
+
data.servers[idx].status = 'online';
|
|
132
|
+
data.servers[idx].lastChecked = new Date().toISOString();
|
|
133
|
+
writeVpsData(vpsJsonPath, data);
|
|
134
|
+
|
|
135
|
+
res.json(info);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
res.status(502).json({ connected: false, error: err.message });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// POST /vps/servers/:id/assign
|
|
142
|
+
router.post('/servers/:id/assign', (req, res) => {
|
|
143
|
+
const { projectId } = req.body;
|
|
144
|
+
if (!projectId) return res.status(400).json({ error: 'projectId required' });
|
|
145
|
+
|
|
146
|
+
const data = readVpsData(vpsJsonPath);
|
|
147
|
+
const idx = data.servers.findIndex(s => s.id === req.params.id);
|
|
148
|
+
if (idx === -1) return res.status(404).json({ error: 'Server not found' });
|
|
149
|
+
|
|
150
|
+
if (!data.servers[idx].assignedProjects) data.servers[idx].assignedProjects = [];
|
|
151
|
+
if (!data.servers[idx].assignedProjects.includes(projectId)) {
|
|
152
|
+
data.servers[idx].assignedProjects.push(projectId);
|
|
153
|
+
}
|
|
154
|
+
writeVpsData(vpsJsonPath, data);
|
|
155
|
+
res.json(data.servers[idx]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// POST /vps/servers/:id/unassign
|
|
159
|
+
router.post('/servers/:id/unassign', (req, res) => {
|
|
160
|
+
const { projectId } = req.body;
|
|
161
|
+
if (!projectId) return res.status(400).json({ error: 'projectId required' });
|
|
162
|
+
|
|
163
|
+
const data = readVpsData(vpsJsonPath);
|
|
164
|
+
const idx = data.servers.findIndex(s => s.id === req.params.id);
|
|
165
|
+
if (idx === -1) return res.status(404).json({ error: 'Server not found' });
|
|
166
|
+
|
|
167
|
+
if (data.servers[idx].assignedProjects) {
|
|
168
|
+
data.servers[idx].assignedProjects = data.servers[idx].assignedProjects.filter(p => p !== projectId);
|
|
169
|
+
}
|
|
170
|
+
writeVpsData(vpsJsonPath, data);
|
|
171
|
+
res.json(data.servers[idx]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// GET /vps/pool
|
|
175
|
+
router.get('/pool', (req, res) => {
|
|
176
|
+
const data = readVpsData(vpsJsonPath);
|
|
177
|
+
const pool = data.servers.filter(s => s.pool === true);
|
|
178
|
+
res.json(pool);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return router;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { createVpsRouter };
|
|
@@ -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
|
+
});
|