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.
Files changed (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. 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
+ });