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,147 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { createDeployEngine } = await import('./deploy-engine.js');
|
|
4
|
+
|
|
5
|
+
function createMockSsh() {
|
|
6
|
+
return {
|
|
7
|
+
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
|
|
8
|
+
execStream: vi.fn().mockResolvedValue(0),
|
|
9
|
+
upload: vi.fn().mockResolvedValue(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('DeployEngine', () => {
|
|
14
|
+
let engine;
|
|
15
|
+
let mockSsh;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
mockSsh = createMockSsh();
|
|
19
|
+
engine = createDeployEngine({ sshClient: mockSsh });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('deploy', () => {
|
|
23
|
+
it('executes git clone + docker compose + nginx steps', async () => {
|
|
24
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
25
|
+
const project = { name: 'myapp', repoUrl: 'git@github.com:user/myapp.git' };
|
|
26
|
+
const progress = [];
|
|
27
|
+
|
|
28
|
+
await engine.deploy(sshConfig, project, { domain: 'myapp.dev', branch: 'main' }, (step) => progress.push(step));
|
|
29
|
+
|
|
30
|
+
// Should have called ssh exec multiple times
|
|
31
|
+
expect(mockSsh.exec.mock.calls.length).toBeGreaterThan(0);
|
|
32
|
+
// Should have progress steps
|
|
33
|
+
expect(progress.length).toBeGreaterThan(0);
|
|
34
|
+
// Verify key steps happened
|
|
35
|
+
const commands = mockSsh.exec.mock.calls.map(c => c[1]);
|
|
36
|
+
expect(commands.some(c => c.includes('git'))).toBe(true);
|
|
37
|
+
expect(commands.some(c => c.includes('docker'))).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('generates correct Nginx config for project domain', async () => {
|
|
41
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
42
|
+
const project = { name: 'myapp', repoUrl: 'git@github.com:user/myapp.git' };
|
|
43
|
+
|
|
44
|
+
await engine.deploy(sshConfig, project, { domain: 'myapp.dev', branch: 'main' });
|
|
45
|
+
|
|
46
|
+
// Should write nginx config
|
|
47
|
+
const commands = mockSsh.exec.mock.calls.map(c => c[1]);
|
|
48
|
+
const nginxWrite = commands.find(c => c.includes('sites-available') || c.includes('nginx'));
|
|
49
|
+
expect(nginxWrite).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('deployBranch', () => {
|
|
54
|
+
it('creates subdomain config for branch', async () => {
|
|
55
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
56
|
+
const project = { name: 'myapp', repoUrl: 'git@github.com:user/myapp.git' };
|
|
57
|
+
|
|
58
|
+
await engine.deployBranch(sshConfig, project, 'feat-login', 'myapp.dev');
|
|
59
|
+
|
|
60
|
+
const commands = mockSsh.exec.mock.calls.map(c => c[1]);
|
|
61
|
+
expect(commands.some(c => c.includes('feat-login'))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('sanitizes branch name for DNS', async () => {
|
|
65
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
66
|
+
const project = { name: 'myapp', repoUrl: 'git@github.com:user/myapp.git' };
|
|
67
|
+
|
|
68
|
+
await engine.deployBranch(sshConfig, project, 'feature/login-page', 'myapp.dev');
|
|
69
|
+
|
|
70
|
+
const commands = mockSsh.exec.mock.calls.map(c => c[1]);
|
|
71
|
+
// Should contain sanitized name (slashes → dashes)
|
|
72
|
+
expect(commands.some(c => c.includes('feature-login-page'))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Phase 81 Task 5: git commands must use original branch name
|
|
76
|
+
it('git clone uses original branch name not sanitized', async () => {
|
|
77
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
78
|
+
const project = { name: 'myapp', repoUrl: 'git@github.com:user/myapp.git' };
|
|
79
|
+
|
|
80
|
+
await engine.deployBranch(sshConfig, project, 'feature/login-page', 'myapp.dev');
|
|
81
|
+
|
|
82
|
+
const commands = mockSsh.exec.mock.calls.map(c => c[1]);
|
|
83
|
+
// git clone -b must use the ORIGINAL branch name (feature/login-page)
|
|
84
|
+
const cloneCmd = commands.find(c => c.includes('git clone'));
|
|
85
|
+
if (cloneCmd) {
|
|
86
|
+
expect(cloneCmd).toContain('-b feature/login-page');
|
|
87
|
+
}
|
|
88
|
+
// git reset/checkout must use the ORIGINAL branch name
|
|
89
|
+
const resetCmd = commands.find(c => c.includes('git reset') || c.includes('git checkout'));
|
|
90
|
+
if (resetCmd) {
|
|
91
|
+
expect(resetCmd).toContain('origin/feature/login-page');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('allocates unique port', async () => {
|
|
96
|
+
mockSsh.exec.mockImplementation(async (config, cmd) => {
|
|
97
|
+
if (cmd.includes('cat') && cmd.includes('ports.json')) {
|
|
98
|
+
return { stdout: '{}', stderr: '', exitCode: 0 };
|
|
99
|
+
}
|
|
100
|
+
return { stdout: '', stderr: '', exitCode: 0 };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
104
|
+
const project = { name: 'myapp', repoUrl: 'git@github.com:user/myapp.git' };
|
|
105
|
+
|
|
106
|
+
const result = await engine.deployBranch(sshConfig, project, 'main', 'myapp.dev');
|
|
107
|
+
expect(result.port).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('rollback', () => {
|
|
112
|
+
it('checks out previous commit', async () => {
|
|
113
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
114
|
+
|
|
115
|
+
await engine.rollback(sshConfig, { name: 'myapp' });
|
|
116
|
+
|
|
117
|
+
const commands = mockSsh.exec.mock.calls.map(c => c[1]);
|
|
118
|
+
expect(commands.some(c => c.includes('git') && c.includes('HEAD~1'))).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('cleanupBranch', () => {
|
|
123
|
+
it('removes container and nginx config', async () => {
|
|
124
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
125
|
+
|
|
126
|
+
await engine.cleanupBranch(sshConfig, { name: 'myapp' }, 'feat-login');
|
|
127
|
+
|
|
128
|
+
const commands = mockSsh.exec.mock.calls.map(c => c[1]);
|
|
129
|
+
expect(commands.some(c => c.includes('docker') && (c.includes('stop') || c.includes('rm')))).toBe(true);
|
|
130
|
+
expect(commands.some(c => c.includes('rm') && c.includes('sites-enabled'))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('listDeployments', () => {
|
|
135
|
+
it('returns active deploys', async () => {
|
|
136
|
+
mockSsh.exec.mockResolvedValue({
|
|
137
|
+
stdout: 'main\nfeat-login\n',
|
|
138
|
+
stderr: '',
|
|
139
|
+
exitCode: 0,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const sshConfig = { host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' };
|
|
143
|
+
const deploys = await engine.listDeployments(sshConfig, { name: 'myapp' });
|
|
144
|
+
expect(Array.isArray(deploys)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker API Router — Express routes for Docker management
|
|
3
|
+
* Phase 80 Task 1
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const express = require('express');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create Docker API router
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {Object} options.dockerClient - Docker client instance
|
|
12
|
+
* @returns {express.Router}
|
|
13
|
+
*/
|
|
14
|
+
function createDockerRouter({ dockerClient }) {
|
|
15
|
+
const router = express.Router();
|
|
16
|
+
|
|
17
|
+
// GET /docker/status
|
|
18
|
+
router.get('/status', async (req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const status = await dockerClient.isAvailable();
|
|
21
|
+
if (!status.available) {
|
|
22
|
+
return res.status(503).json(status);
|
|
23
|
+
}
|
|
24
|
+
res.json(status);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
res.status(503).json({ available: false, error: err.message });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// GET /docker/containers
|
|
31
|
+
router.get('/containers', async (req, res) => {
|
|
32
|
+
try {
|
|
33
|
+
const all = req.query.all === 'true';
|
|
34
|
+
const containers = await dockerClient.listContainers(all);
|
|
35
|
+
res.json(containers);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
res.status(500).json({ error: err.message });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// GET /docker/containers/:id
|
|
42
|
+
router.get('/containers/:id', async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const detail = await dockerClient.getContainer(req.params.id);
|
|
45
|
+
res.json(detail);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const status = err.statusCode === 404 ? 404 : 500;
|
|
48
|
+
res.status(status).json({ error: err.message });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// POST /docker/containers/:id/start
|
|
53
|
+
router.post('/containers/:id/start', async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
await dockerClient.startContainer(req.params.id);
|
|
56
|
+
res.json({ ok: true });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
res.status(500).json({ error: err.message });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// POST /docker/containers/:id/stop
|
|
63
|
+
router.post('/containers/:id/stop', async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
await dockerClient.stopContainer(req.params.id);
|
|
66
|
+
res.json({ ok: true });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
res.status(500).json({ error: err.message });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// POST /docker/containers/:id/restart
|
|
73
|
+
router.post('/containers/:id/restart', async (req, res) => {
|
|
74
|
+
try {
|
|
75
|
+
await dockerClient.restartContainer(req.params.id);
|
|
76
|
+
res.json({ ok: true });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
res.status(500).json({ error: err.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// DELETE /docker/containers/:id
|
|
83
|
+
router.delete('/containers/:id', async (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const force = req.query.force === 'true';
|
|
86
|
+
await dockerClient.removeContainer(req.params.id, force);
|
|
87
|
+
res.json({ ok: true });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
res.status(500).json({ error: err.message });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// GET /docker/containers/:id/logs
|
|
94
|
+
router.get('/containers/:id/logs', async (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const tail = parseInt(req.query.tail, 10) || 100;
|
|
97
|
+
const logs = await dockerClient.getContainerLogs(req.params.id, { tail });
|
|
98
|
+
res.json({ logs });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
res.status(500).json({ error: err.message });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// GET /docker/containers/:id/stats
|
|
105
|
+
router.get('/containers/:id/stats', async (req, res) => {
|
|
106
|
+
try {
|
|
107
|
+
const stats = await dockerClient.getContainerStats(req.params.id);
|
|
108
|
+
res.json(stats);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
res.status(500).json({ error: err.message });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// GET /docker/images
|
|
115
|
+
router.get('/images', async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const images = await dockerClient.listImages();
|
|
118
|
+
res.json(images);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
res.status(500).json({ error: err.message });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// GET /docker/volumes
|
|
125
|
+
router.get('/volumes', async (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
const volumes = await dockerClient.listVolumes();
|
|
128
|
+
res.json(volumes);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
res.status(500).json({ error: err.message });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return router;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { createDockerRouter };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
|
|
5
|
+
const { createDockerRouter } = await import('./docker-api.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper: create mock docker client
|
|
9
|
+
*/
|
|
10
|
+
function createMockDockerClient(available = true) {
|
|
11
|
+
return {
|
|
12
|
+
isAvailable: vi.fn().mockResolvedValue(
|
|
13
|
+
available
|
|
14
|
+
? { available: true, version: '24.0.0', apiVersion: '1.43' }
|
|
15
|
+
: { available: false, error: 'Docker not available' }
|
|
16
|
+
),
|
|
17
|
+
listContainers: vi.fn().mockResolvedValue([
|
|
18
|
+
{
|
|
19
|
+
id: 'abc123',
|
|
20
|
+
name: 'tlc-dev-dashboard',
|
|
21
|
+
image: 'node:20-alpine',
|
|
22
|
+
state: 'running',
|
|
23
|
+
status: 'Up 2 hours',
|
|
24
|
+
ports: [{ private: 3147, public: 3147, type: 'tcp' }],
|
|
25
|
+
created: 1708300000,
|
|
26
|
+
labels: {},
|
|
27
|
+
},
|
|
28
|
+
]),
|
|
29
|
+
getContainer: vi.fn().mockResolvedValue({
|
|
30
|
+
id: 'abc123',
|
|
31
|
+
name: 'tlc-dev-dashboard',
|
|
32
|
+
image: 'node:20-alpine',
|
|
33
|
+
state: 'running',
|
|
34
|
+
env: ['NODE_ENV=development'],
|
|
35
|
+
mounts: [],
|
|
36
|
+
networks: {},
|
|
37
|
+
}),
|
|
38
|
+
startContainer: vi.fn().mockResolvedValue(),
|
|
39
|
+
stopContainer: vi.fn().mockResolvedValue(),
|
|
40
|
+
restartContainer: vi.fn().mockResolvedValue(),
|
|
41
|
+
removeContainer: vi.fn().mockResolvedValue(),
|
|
42
|
+
getContainerStats: vi.fn().mockResolvedValue({
|
|
43
|
+
cpuPercent: 2.5,
|
|
44
|
+
memoryUsage: 104857600,
|
|
45
|
+
memoryLimit: 2147483648,
|
|
46
|
+
networkRx: 1024000,
|
|
47
|
+
networkTx: 512000,
|
|
48
|
+
}),
|
|
49
|
+
getContainerLogs: vi.fn().mockResolvedValue('log line 1\nlog line 2\n'),
|
|
50
|
+
listImages: vi.fn().mockResolvedValue([
|
|
51
|
+
{ id: 'sha256:abc', tags: ['node:20-alpine'], size: 180000000, created: 1708200000 },
|
|
52
|
+
]),
|
|
53
|
+
listVolumes: vi.fn().mockResolvedValue([
|
|
54
|
+
{ name: 'postgres-data', driver: 'local', mountpoint: '/var/lib/docker/volumes/pg/_data' },
|
|
55
|
+
]),
|
|
56
|
+
matchContainerToProject: vi.fn().mockReturnValue(null),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createApp(dockerClient) {
|
|
61
|
+
const app = express();
|
|
62
|
+
app.use(express.json());
|
|
63
|
+
app.use('/docker', createDockerRouter({ dockerClient }));
|
|
64
|
+
return app;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('Docker API Router', () => {
|
|
68
|
+
let mockClient;
|
|
69
|
+
let app;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
mockClient = createMockDockerClient();
|
|
73
|
+
app = createApp(mockClient);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('GET /docker/status', () => {
|
|
77
|
+
it('returns Docker status and version', async () => {
|
|
78
|
+
const res = await request(app).get('/docker/status');
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(res.body.available).toBe(true);
|
|
81
|
+
expect(res.body.version).toBe('24.0.0');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns 503 when Docker unavailable', async () => {
|
|
85
|
+
const unavailableClient = createMockDockerClient(false);
|
|
86
|
+
const unavailableApp = createApp(unavailableClient);
|
|
87
|
+
const res = await request(unavailableApp).get('/docker/status');
|
|
88
|
+
expect(res.status).toBe(503);
|
|
89
|
+
expect(res.body.available).toBe(false);
|
|
90
|
+
expect(res.body.error).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('GET /docker/containers', () => {
|
|
95
|
+
it('returns list of containers', async () => {
|
|
96
|
+
const res = await request(app).get('/docker/containers');
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
expect(res.body).toHaveLength(1);
|
|
99
|
+
expect(res.body[0].name).toBe('tlc-dev-dashboard');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('passes all=true query parameter', async () => {
|
|
103
|
+
await request(app).get('/docker/containers?all=true');
|
|
104
|
+
expect(mockClient.listContainers).toHaveBeenCalledWith(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('GET /docker/containers/:id', () => {
|
|
109
|
+
it('returns container detail', async () => {
|
|
110
|
+
const res = await request(app).get('/docker/containers/abc123');
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
expect(res.body.id).toBe('abc123');
|
|
113
|
+
expect(mockClient.getContainer).toHaveBeenCalledWith('abc123');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns 404 for unknown container', async () => {
|
|
117
|
+
mockClient.getContainer.mockRejectedValue(
|
|
118
|
+
Object.assign(new Error('no such container'), { statusCode: 404 })
|
|
119
|
+
);
|
|
120
|
+
const res = await request(app).get('/docker/containers/nonexistent');
|
|
121
|
+
expect(res.status).toBe(404);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('POST /docker/containers/:id/start', () => {
|
|
126
|
+
it('starts a container', async () => {
|
|
127
|
+
const res = await request(app).post('/docker/containers/abc123/start');
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
expect(mockClient.startContainer).toHaveBeenCalledWith('abc123');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('POST /docker/containers/:id/stop', () => {
|
|
134
|
+
it('stops a container', async () => {
|
|
135
|
+
const res = await request(app).post('/docker/containers/abc123/stop');
|
|
136
|
+
expect(res.status).toBe(200);
|
|
137
|
+
expect(mockClient.stopContainer).toHaveBeenCalledWith('abc123');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('POST /docker/containers/:id/restart', () => {
|
|
142
|
+
it('restarts a container', async () => {
|
|
143
|
+
const res = await request(app).post('/docker/containers/abc123/restart');
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
expect(mockClient.restartContainer).toHaveBeenCalledWith('abc123');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('DELETE /docker/containers/:id', () => {
|
|
150
|
+
it('removes a container', async () => {
|
|
151
|
+
const res = await request(app).delete('/docker/containers/abc123');
|
|
152
|
+
expect(res.status).toBe(200);
|
|
153
|
+
expect(mockClient.removeContainer).toHaveBeenCalledWith('abc123', false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('removes with force when requested', async () => {
|
|
157
|
+
const res = await request(app).delete('/docker/containers/abc123?force=true');
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
expect(mockClient.removeContainer).toHaveBeenCalledWith('abc123', true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('GET /docker/containers/:id/logs', () => {
|
|
164
|
+
it('returns container logs', async () => {
|
|
165
|
+
const res = await request(app).get('/docker/containers/abc123/logs');
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
expect(res.body.logs).toContain('log line 1');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('passes tail parameter', async () => {
|
|
171
|
+
await request(app).get('/docker/containers/abc123/logs?tail=50');
|
|
172
|
+
expect(mockClient.getContainerLogs).toHaveBeenCalledWith('abc123', { tail: 50 });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('GET /docker/containers/:id/stats', () => {
|
|
177
|
+
it('returns container stats snapshot', async () => {
|
|
178
|
+
const res = await request(app).get('/docker/containers/abc123/stats');
|
|
179
|
+
expect(res.status).toBe(200);
|
|
180
|
+
expect(res.body.cpuPercent).toBe(2.5);
|
|
181
|
+
expect(res.body.memoryUsage).toBe(104857600);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('GET /docker/images', () => {
|
|
186
|
+
it('returns list of images', async () => {
|
|
187
|
+
const res = await request(app).get('/docker/images');
|
|
188
|
+
expect(res.status).toBe(200);
|
|
189
|
+
expect(res.body).toHaveLength(1);
|
|
190
|
+
expect(res.body[0].tags).toContain('node:20-alpine');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('GET /docker/volumes', () => {
|
|
195
|
+
it('returns list of volumes', async () => {
|
|
196
|
+
const res = await request(app).get('/docker/volumes');
|
|
197
|
+
expect(res.status).toBe(200);
|
|
198
|
+
expect(res.body).toHaveLength(1);
|
|
199
|
+
expect(res.body[0].name).toBe('postgres-data');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|