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,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
+ });