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,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Client — wraps dockerode for Docker socket communication
|
|
3
|
+
* Phase 80 Task 1
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const Docker = require('dockerode');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a Docker client instance
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {string} [options.socketPath=/var/run/docker.sock] - Docker socket path
|
|
12
|
+
* @param {Object} [options._docker] - Injected Docker instance (for testing)
|
|
13
|
+
* @returns {Object} Docker client API
|
|
14
|
+
*/
|
|
15
|
+
function createDockerClient(options = {}) {
|
|
16
|
+
const socketPath = options.socketPath || '/var/run/docker.sock';
|
|
17
|
+
const docker = options._docker || new Docker({ socketPath });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if Docker daemon is accessible
|
|
21
|
+
*/
|
|
22
|
+
async function isAvailable() {
|
|
23
|
+
try {
|
|
24
|
+
await docker.ping();
|
|
25
|
+
const info = await docker.version();
|
|
26
|
+
return { available: true, version: info.Version, apiVersion: info.ApiVersion };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return { available: false, error: err.message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* List containers
|
|
34
|
+
* @param {boolean} [all=false] - Include stopped containers
|
|
35
|
+
*/
|
|
36
|
+
async function listContainers(all = false) {
|
|
37
|
+
const containers = await docker.listContainers({ all });
|
|
38
|
+
return containers.map(c => ({
|
|
39
|
+
id: c.Id,
|
|
40
|
+
name: (c.Names[0] || '').replace(/^\//, ''),
|
|
41
|
+
image: c.Image,
|
|
42
|
+
state: c.State,
|
|
43
|
+
status: c.Status,
|
|
44
|
+
ports: (c.Ports || []).map(p => ({
|
|
45
|
+
private: p.PrivatePort,
|
|
46
|
+
public: p.PublicPort,
|
|
47
|
+
type: p.Type,
|
|
48
|
+
})),
|
|
49
|
+
created: c.Created,
|
|
50
|
+
labels: c.Labels || {},
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get container detail
|
|
56
|
+
* @param {string} id - Container ID or name
|
|
57
|
+
*/
|
|
58
|
+
async function getContainer(id) {
|
|
59
|
+
const container = docker.getContainer(id);
|
|
60
|
+
const info = await container.inspect();
|
|
61
|
+
return {
|
|
62
|
+
id: info.Id,
|
|
63
|
+
name: (info.Name || '').replace(/^\//, ''),
|
|
64
|
+
image: info.Config.Image,
|
|
65
|
+
state: info.State.Status,
|
|
66
|
+
startedAt: info.State.StartedAt,
|
|
67
|
+
env: info.Config.Env || [],
|
|
68
|
+
mounts: (info.Mounts || []).map(m => ({
|
|
69
|
+
source: m.Source,
|
|
70
|
+
destination: m.Destination,
|
|
71
|
+
rw: m.RW,
|
|
72
|
+
})),
|
|
73
|
+
networks: info.NetworkSettings.Networks || {},
|
|
74
|
+
ports: info.HostConfig.PortBindings || {},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start a container
|
|
80
|
+
* @param {string} id - Container ID
|
|
81
|
+
*/
|
|
82
|
+
async function startContainer(id) {
|
|
83
|
+
const container = docker.getContainer(id);
|
|
84
|
+
await container.start();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stop a container
|
|
89
|
+
* @param {string} id - Container ID
|
|
90
|
+
*/
|
|
91
|
+
async function stopContainer(id) {
|
|
92
|
+
const container = docker.getContainer(id);
|
|
93
|
+
await container.stop();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Restart a container
|
|
98
|
+
* @param {string} id - Container ID
|
|
99
|
+
*/
|
|
100
|
+
async function restartContainer(id) {
|
|
101
|
+
const container = docker.getContainer(id);
|
|
102
|
+
await container.restart();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove a container
|
|
107
|
+
* @param {string} id - Container ID
|
|
108
|
+
* @param {boolean} [force=false] - Force removal
|
|
109
|
+
*/
|
|
110
|
+
async function removeContainer(id, force = false) {
|
|
111
|
+
const container = docker.getContainer(id);
|
|
112
|
+
await container.remove({ force });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get container stats snapshot
|
|
117
|
+
* @param {string} id - Container ID
|
|
118
|
+
*/
|
|
119
|
+
async function getContainerStats(id) {
|
|
120
|
+
const container = docker.getContainer(id);
|
|
121
|
+
const stats = await container.stats({ stream: false });
|
|
122
|
+
|
|
123
|
+
// Calculate CPU %
|
|
124
|
+
const cpuDelta = (stats.cpu_stats?.cpu_usage?.total_usage || 0) - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
|
|
125
|
+
const systemDelta = (stats.cpu_stats?.system_cpu_usage || 0) - (stats.precpu_stats?.system_cpu_usage || 0);
|
|
126
|
+
const numCpus = stats.cpu_stats?.online_cpus || 1;
|
|
127
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0;
|
|
128
|
+
|
|
129
|
+
// Memory
|
|
130
|
+
const cache = stats.memory_stats?.stats?.cache || stats.memory_stats?.stats?.inactive_file || 0;
|
|
131
|
+
const memoryUsage = (stats.memory_stats?.usage || 0) - cache;
|
|
132
|
+
const memoryLimit = stats.memory_stats?.limit || 0;
|
|
133
|
+
|
|
134
|
+
// Network
|
|
135
|
+
let networkRx = 0;
|
|
136
|
+
let networkTx = 0;
|
|
137
|
+
if (stats.networks) {
|
|
138
|
+
for (const iface of Object.values(stats.networks)) {
|
|
139
|
+
networkRx += iface.rx_bytes || 0;
|
|
140
|
+
networkTx += iface.tx_bytes || 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { cpuPercent, memoryUsage, memoryLimit, networkRx, networkTx };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get container logs
|
|
149
|
+
* @param {string} id - Container ID
|
|
150
|
+
* @param {Object} [opts]
|
|
151
|
+
* @param {number} [opts.tail=100] - Number of lines
|
|
152
|
+
*/
|
|
153
|
+
async function getContainerLogs(id, opts = {}) {
|
|
154
|
+
const container = docker.getContainer(id);
|
|
155
|
+
const logs = await container.logs({
|
|
156
|
+
stdout: true,
|
|
157
|
+
stderr: true,
|
|
158
|
+
tail: opts.tail || 100,
|
|
159
|
+
timestamps: true,
|
|
160
|
+
});
|
|
161
|
+
// logs may be Buffer or string
|
|
162
|
+
return typeof logs === 'string' ? logs : logs.toString('utf8');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stream container logs (live)
|
|
167
|
+
* @param {string} id - Container ID
|
|
168
|
+
* @param {Function} callback - Called with each log chunk
|
|
169
|
+
* @returns {Function} abort function
|
|
170
|
+
*/
|
|
171
|
+
function streamContainerLogs(id, callback) {
|
|
172
|
+
let aborted = false;
|
|
173
|
+
let streamRef = null;
|
|
174
|
+
const container = docker.getContainer(id);
|
|
175
|
+
container.logs({ follow: true, stdout: true, stderr: true, tail: 50, timestamps: true })
|
|
176
|
+
.then(stream => {
|
|
177
|
+
streamRef = stream;
|
|
178
|
+
if (aborted) { stream.destroy && stream.destroy(); return; }
|
|
179
|
+
stream.on('data', chunk => {
|
|
180
|
+
if (!aborted) callback(chunk.toString('utf8'));
|
|
181
|
+
});
|
|
182
|
+
stream.on('end', () => {});
|
|
183
|
+
})
|
|
184
|
+
.catch((err) => { callback && callback(null, err); });
|
|
185
|
+
return () => { aborted = true; if (streamRef) { streamRef.destroy && streamRef.destroy(); } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stream container stats (live)
|
|
190
|
+
* @param {string} id - Container ID
|
|
191
|
+
* @param {Function} callback - Called with each stats update
|
|
192
|
+
* @returns {Function} abort function
|
|
193
|
+
*/
|
|
194
|
+
function streamContainerStats(id, callback) {
|
|
195
|
+
let aborted = false;
|
|
196
|
+
let streamRef = null;
|
|
197
|
+
const container = docker.getContainer(id);
|
|
198
|
+
container.stats({ stream: true })
|
|
199
|
+
.then(stream => {
|
|
200
|
+
streamRef = stream;
|
|
201
|
+
if (aborted) { stream.destroy && stream.destroy(); return; }
|
|
202
|
+
let buffer = '';
|
|
203
|
+
stream.on('data', chunk => {
|
|
204
|
+
if (aborted) { stream.destroy(); return; }
|
|
205
|
+
buffer += chunk.toString('utf8');
|
|
206
|
+
const lines = buffer.split('\n');
|
|
207
|
+
buffer = lines.pop();
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
if (line.trim()) {
|
|
210
|
+
try {
|
|
211
|
+
const stats = JSON.parse(line);
|
|
212
|
+
const cpuDelta = (stats.cpu_stats?.cpu_usage?.total_usage || 0) - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
|
|
213
|
+
const systemDelta = (stats.cpu_stats?.system_cpu_usage || 0) - (stats.precpu_stats?.system_cpu_usage || 0);
|
|
214
|
+
const numCpus = stats.cpu_stats?.online_cpus || 1;
|
|
215
|
+
callback({
|
|
216
|
+
cpuPercent: systemDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0,
|
|
217
|
+
memoryUsage: stats.memory_stats?.usage || 0,
|
|
218
|
+
memoryLimit: stats.memory_stats?.limit || 0,
|
|
219
|
+
});
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
})
|
|
225
|
+
.catch((err) => { callback && callback(null, err); });
|
|
226
|
+
return () => { aborted = true; if (streamRef) { streamRef.destroy && streamRef.destroy(); } };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* List images
|
|
231
|
+
*/
|
|
232
|
+
async function listImages() {
|
|
233
|
+
const images = await docker.listImages();
|
|
234
|
+
return images.map(img => ({
|
|
235
|
+
id: img.Id,
|
|
236
|
+
tags: img.RepoTags || [],
|
|
237
|
+
size: img.Size,
|
|
238
|
+
created: img.Created,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* List volumes
|
|
244
|
+
*/
|
|
245
|
+
async function listVolumes() {
|
|
246
|
+
const result = await docker.listVolumes();
|
|
247
|
+
return (result.Volumes || []).map(v => ({
|
|
248
|
+
name: v.Name,
|
|
249
|
+
driver: v.Driver,
|
|
250
|
+
mountpoint: v.Mountpoint,
|
|
251
|
+
createdAt: v.CreatedAt,
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Match a container to a TLC project by name or labels
|
|
257
|
+
* @param {Object} container - { name, labels }
|
|
258
|
+
* @param {Array} projects - [{ name, path }]
|
|
259
|
+
* @returns {string|null} matched project name or null
|
|
260
|
+
*/
|
|
261
|
+
function matchContainerToProject(container, projects) {
|
|
262
|
+
// Match by compose project label
|
|
263
|
+
const composeProject = container.labels && container.labels['com.docker.compose.project'];
|
|
264
|
+
if (composeProject) {
|
|
265
|
+
const match = projects.find(p => p.name.toLowerCase() === composeProject.toLowerCase());
|
|
266
|
+
if (match) return match.name;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Match by container name containing project name
|
|
270
|
+
const cName = (container.name || '').toLowerCase();
|
|
271
|
+
for (const project of projects) {
|
|
272
|
+
const pName = project.name.toLowerCase();
|
|
273
|
+
if (cName.includes(pName)) return project.name;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
isAvailable,
|
|
281
|
+
listContainers,
|
|
282
|
+
getContainer,
|
|
283
|
+
startContainer,
|
|
284
|
+
stopContainer,
|
|
285
|
+
restartContainer,
|
|
286
|
+
removeContainer,
|
|
287
|
+
getContainerStats,
|
|
288
|
+
getContainerLogs,
|
|
289
|
+
streamContainerLogs,
|
|
290
|
+
streamContainerStats,
|
|
291
|
+
listImages,
|
|
292
|
+
listVolumes,
|
|
293
|
+
matchContainerToProject,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { createDockerClient };
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { createDockerClient } = await import('./docker-client.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a mock dockerode instance for injection
|
|
7
|
+
*/
|
|
8
|
+
function createMockDocker() {
|
|
9
|
+
const mockContainer = {
|
|
10
|
+
inspect: vi.fn(),
|
|
11
|
+
start: vi.fn(),
|
|
12
|
+
stop: vi.fn(),
|
|
13
|
+
restart: vi.fn(),
|
|
14
|
+
remove: vi.fn(),
|
|
15
|
+
stats: vi.fn(),
|
|
16
|
+
logs: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const mockDocker = {
|
|
20
|
+
listContainers: vi.fn(),
|
|
21
|
+
getContainer: vi.fn(() => mockContainer),
|
|
22
|
+
listImages: vi.fn(),
|
|
23
|
+
listVolumes: vi.fn(),
|
|
24
|
+
ping: vi.fn(),
|
|
25
|
+
version: vi.fn(),
|
|
26
|
+
getEvents: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return { mockDocker, mockContainer };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('DockerClient', () => {
|
|
33
|
+
let client;
|
|
34
|
+
let mockDocker;
|
|
35
|
+
let mockContainer;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
const mocks = createMockDocker();
|
|
39
|
+
mockDocker = mocks.mockDocker;
|
|
40
|
+
mockContainer = mocks.mockContainer;
|
|
41
|
+
client = createDockerClient({ _docker: mockDocker });
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('isAvailable', () => {
|
|
46
|
+
it('returns true when Docker socket is accessible', async () => {
|
|
47
|
+
mockDocker.ping.mockResolvedValue('OK');
|
|
48
|
+
mockDocker.version.mockResolvedValue({ Version: '24.0.0', ApiVersion: '1.43' });
|
|
49
|
+
|
|
50
|
+
const result = await client.isAvailable();
|
|
51
|
+
expect(result).toEqual({
|
|
52
|
+
available: true,
|
|
53
|
+
version: '24.0.0',
|
|
54
|
+
apiVersion: '1.43',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns false when Docker socket is missing', async () => {
|
|
59
|
+
mockDocker.ping.mockRejectedValue(new Error('connect ENOENT /var/run/docker.sock'));
|
|
60
|
+
|
|
61
|
+
const result = await client.isAvailable();
|
|
62
|
+
expect(result).toEqual({
|
|
63
|
+
available: false,
|
|
64
|
+
error: expect.stringContaining('ENOENT'),
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('listContainers', () => {
|
|
70
|
+
it('returns formatted container objects', async () => {
|
|
71
|
+
mockDocker.listContainers.mockResolvedValue([
|
|
72
|
+
{
|
|
73
|
+
Id: 'abc123def456',
|
|
74
|
+
Names: ['/tlc-dev-dashboard'],
|
|
75
|
+
Image: 'node:20-alpine',
|
|
76
|
+
State: 'running',
|
|
77
|
+
Status: 'Up 2 hours',
|
|
78
|
+
Ports: [{ PrivatePort: 3147, PublicPort: 3147, Type: 'tcp' }],
|
|
79
|
+
Created: 1708300000,
|
|
80
|
+
Labels: { 'com.docker.compose.project': 'tlc' },
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const containers = await client.listContainers();
|
|
85
|
+
expect(containers).toHaveLength(1);
|
|
86
|
+
expect(containers[0]).toEqual({
|
|
87
|
+
id: 'abc123def456',
|
|
88
|
+
name: 'tlc-dev-dashboard',
|
|
89
|
+
image: 'node:20-alpine',
|
|
90
|
+
state: 'running',
|
|
91
|
+
status: 'Up 2 hours',
|
|
92
|
+
ports: [{ private: 3147, public: 3147, type: 'tcp' }],
|
|
93
|
+
created: 1708300000,
|
|
94
|
+
labels: { 'com.docker.compose.project': 'tlc' },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('lists all containers including stopped when all=true', async () => {
|
|
99
|
+
mockDocker.listContainers.mockResolvedValue([]);
|
|
100
|
+
await client.listContainers(true);
|
|
101
|
+
expect(mockDocker.listContainers).toHaveBeenCalledWith({ all: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('lists only running containers by default', async () => {
|
|
105
|
+
mockDocker.listContainers.mockResolvedValue([]);
|
|
106
|
+
await client.listContainers();
|
|
107
|
+
expect(mockDocker.listContainers).toHaveBeenCalledWith({ all: false });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('getContainer', () => {
|
|
112
|
+
it('returns full detail for valid container ID', async () => {
|
|
113
|
+
mockContainer.inspect.mockResolvedValue({
|
|
114
|
+
Id: 'abc123',
|
|
115
|
+
Name: '/tlc-dev-dashboard',
|
|
116
|
+
Config: {
|
|
117
|
+
Image: 'node:20-alpine',
|
|
118
|
+
Env: ['NODE_ENV=development', 'TLC_PORT=3147'],
|
|
119
|
+
},
|
|
120
|
+
State: { Status: 'running', StartedAt: '2026-02-18T00:00:00Z' },
|
|
121
|
+
Mounts: [{ Source: '/home/user/tlc', Destination: '/tlc', RW: true }],
|
|
122
|
+
NetworkSettings: { Networks: { bridge: { IPAddress: '172.17.0.2' } } },
|
|
123
|
+
HostConfig: { PortBindings: { '3147/tcp': [{ HostPort: '3147' }] } },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const detail = await client.getContainer('abc123');
|
|
127
|
+
expect(detail.id).toBe('abc123');
|
|
128
|
+
expect(detail.name).toBe('tlc-dev-dashboard');
|
|
129
|
+
expect(detail.image).toBe('node:20-alpine');
|
|
130
|
+
expect(detail.state).toBe('running');
|
|
131
|
+
expect(detail.env).toContain('NODE_ENV=development');
|
|
132
|
+
expect(detail.mounts).toHaveLength(1);
|
|
133
|
+
expect(detail.mounts[0].source).toBe('/home/user/tlc');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws for non-existent container', async () => {
|
|
137
|
+
mockContainer.inspect.mockRejectedValue(
|
|
138
|
+
Object.assign(new Error('no such container'), { statusCode: 404 })
|
|
139
|
+
);
|
|
140
|
+
await expect(client.getContainer('nonexistent')).rejects.toThrow('no such container');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('startContainer', () => {
|
|
145
|
+
it('calls dockerode start', async () => {
|
|
146
|
+
mockContainer.start.mockResolvedValue();
|
|
147
|
+
await client.startContainer('abc123');
|
|
148
|
+
expect(mockDocker.getContainer).toHaveBeenCalledWith('abc123');
|
|
149
|
+
expect(mockContainer.start).toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('stopContainer', () => {
|
|
154
|
+
it('calls dockerode stop', async () => {
|
|
155
|
+
mockContainer.stop.mockResolvedValue();
|
|
156
|
+
await client.stopContainer('abc123');
|
|
157
|
+
expect(mockDocker.getContainer).toHaveBeenCalledWith('abc123');
|
|
158
|
+
expect(mockContainer.stop).toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('restartContainer', () => {
|
|
163
|
+
it('calls dockerode restart', async () => {
|
|
164
|
+
mockContainer.restart.mockResolvedValue();
|
|
165
|
+
await client.restartContainer('abc123');
|
|
166
|
+
expect(mockDocker.getContainer).toHaveBeenCalledWith('abc123');
|
|
167
|
+
expect(mockContainer.restart).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('removeContainer', () => {
|
|
172
|
+
it('removes container with force option', async () => {
|
|
173
|
+
mockContainer.remove.mockResolvedValue();
|
|
174
|
+
await client.removeContainer('abc123', true);
|
|
175
|
+
expect(mockContainer.remove).toHaveBeenCalledWith({ force: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('removes container without force by default', async () => {
|
|
179
|
+
mockContainer.remove.mockResolvedValue();
|
|
180
|
+
await client.removeContainer('abc123');
|
|
181
|
+
expect(mockContainer.remove).toHaveBeenCalledWith({ force: false });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('getContainerStats', () => {
|
|
186
|
+
it('calculates CPU percentage from raw stats', async () => {
|
|
187
|
+
mockContainer.stats.mockResolvedValue({
|
|
188
|
+
cpu_stats: {
|
|
189
|
+
cpu_usage: { total_usage: 500000000 },
|
|
190
|
+
system_cpu_usage: 10000000000,
|
|
191
|
+
online_cpus: 4,
|
|
192
|
+
},
|
|
193
|
+
precpu_stats: {
|
|
194
|
+
cpu_usage: { total_usage: 400000000 },
|
|
195
|
+
system_cpu_usage: 9000000000,
|
|
196
|
+
},
|
|
197
|
+
memory_stats: {
|
|
198
|
+
usage: 104857600,
|
|
199
|
+
limit: 2147483648,
|
|
200
|
+
stats: { cache: 10485760 },
|
|
201
|
+
},
|
|
202
|
+
networks: {
|
|
203
|
+
eth0: { rx_bytes: 1024000, tx_bytes: 512000 },
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const stats = await client.getContainerStats('abc123');
|
|
208
|
+
expect(stats.cpuPercent).toBeGreaterThan(0);
|
|
209
|
+
expect(stats.memoryUsage).toBeGreaterThan(0);
|
|
210
|
+
expect(stats.memoryLimit).toBe(2147483648);
|
|
211
|
+
expect(stats.networkRx).toBe(1024000);
|
|
212
|
+
expect(stats.networkTx).toBe(512000);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('getContainerLogs', () => {
|
|
217
|
+
it('returns recent log lines', async () => {
|
|
218
|
+
const mockStream = Buffer.from('line1\nline2\nline3\n');
|
|
219
|
+
mockContainer.logs.mockResolvedValue(mockStream);
|
|
220
|
+
|
|
221
|
+
const logs = await client.getContainerLogs('abc123', { tail: 100 });
|
|
222
|
+
expect(mockContainer.logs).toHaveBeenCalledWith({
|
|
223
|
+
stdout: true,
|
|
224
|
+
stderr: true,
|
|
225
|
+
tail: 100,
|
|
226
|
+
timestamps: true,
|
|
227
|
+
});
|
|
228
|
+
expect(typeof logs).toBe('string');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('listImages', () => {
|
|
233
|
+
it('returns formatted image objects', async () => {
|
|
234
|
+
mockDocker.listImages.mockResolvedValue([
|
|
235
|
+
{
|
|
236
|
+
Id: 'sha256:abc123',
|
|
237
|
+
RepoTags: ['node:20-alpine'],
|
|
238
|
+
Size: 180000000,
|
|
239
|
+
Created: 1708200000,
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
const images = await client.listImages();
|
|
244
|
+
expect(images).toHaveLength(1);
|
|
245
|
+
expect(images[0]).toEqual({
|
|
246
|
+
id: 'sha256:abc123',
|
|
247
|
+
tags: ['node:20-alpine'],
|
|
248
|
+
size: 180000000,
|
|
249
|
+
created: 1708200000,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('listVolumes', () => {
|
|
255
|
+
it('returns formatted volume objects', async () => {
|
|
256
|
+
mockDocker.listVolumes.mockResolvedValue({
|
|
257
|
+
Volumes: [
|
|
258
|
+
{
|
|
259
|
+
Name: 'postgres-data',
|
|
260
|
+
Driver: 'local',
|
|
261
|
+
Mountpoint: '/var/lib/docker/volumes/postgres-data/_data',
|
|
262
|
+
CreatedAt: '2026-02-18T00:00:00Z',
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const volumes = await client.listVolumes();
|
|
268
|
+
expect(volumes).toHaveLength(1);
|
|
269
|
+
expect(volumes[0]).toEqual({
|
|
270
|
+
name: 'postgres-data',
|
|
271
|
+
driver: 'local',
|
|
272
|
+
mountpoint: '/var/lib/docker/volumes/postgres-data/_data',
|
|
273
|
+
createdAt: '2026-02-18T00:00:00Z',
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('matchContainerToProject', () => {
|
|
279
|
+
it('matches by container name pattern', () => {
|
|
280
|
+
const container = { name: 'tlc-myapp-dashboard', labels: {} };
|
|
281
|
+
const projects = [
|
|
282
|
+
{ name: 'myapp', path: '/home/user/myapp' },
|
|
283
|
+
{ name: 'other', path: '/home/user/other' },
|
|
284
|
+
];
|
|
285
|
+
const match = client.matchContainerToProject(container, projects);
|
|
286
|
+
expect(match).toBe('myapp');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('matches by compose project label', () => {
|
|
290
|
+
const container = {
|
|
291
|
+
name: 'some-random-name',
|
|
292
|
+
labels: { 'com.docker.compose.project': 'myapp' },
|
|
293
|
+
};
|
|
294
|
+
const projects = [
|
|
295
|
+
{ name: 'myapp', path: '/home/user/myapp' },
|
|
296
|
+
];
|
|
297
|
+
const match = client.matchContainerToProject(container, projects);
|
|
298
|
+
expect(match).toBe('myapp');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('returns null when no match found', () => {
|
|
302
|
+
const container = { name: 'unrelated-container', labels: {} };
|
|
303
|
+
const projects = [{ name: 'myapp', path: '/home/user/myapp' }];
|
|
304
|
+
const match = client.matchContainerToProject(container, projects);
|
|
305
|
+
expect(match).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Sanitizer — validation for user-supplied values used in shell commands
|
|
3
|
+
* Phase 80 Review Fix
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Strict DNS hostname pattern */
|
|
7
|
+
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
8
|
+
|
|
9
|
+
/** Safe git branch pattern (allows slashes, dots, dashes, underscores) */
|
|
10
|
+
const BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/;
|
|
11
|
+
|
|
12
|
+
/** Safe git repo URL pattern (git@... or https://...) */
|
|
13
|
+
const REPO_URL_RE = /^(git@[\w.-]+:[\w./-]+\.git|https?:\/\/[\w.-]+(\/[\w./-]+)*(\.git)?)$/;
|
|
14
|
+
|
|
15
|
+
/** Safe unix username pattern */
|
|
16
|
+
const USERNAME_RE = /^[a-z_][a-z0-9_-]*$/;
|
|
17
|
+
|
|
18
|
+
/** Safe project name pattern */
|
|
19
|
+
const PROJECT_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate a DNS hostname/domain
|
|
23
|
+
* @param {string} domain
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isValidDomain(domain) {
|
|
27
|
+
if (!domain || typeof domain !== 'string') return false;
|
|
28
|
+
if (domain.length > 253) return false;
|
|
29
|
+
return DOMAIN_RE.test(domain);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate a git branch name
|
|
34
|
+
* @param {string} branch
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function isValidBranch(branch) {
|
|
38
|
+
if (!branch || typeof branch !== 'string') return false;
|
|
39
|
+
if (branch.length > 255) return false;
|
|
40
|
+
if (branch.includes('..')) return false;
|
|
41
|
+
return BRANCH_RE.test(branch);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a git repo URL
|
|
46
|
+
* @param {string} url
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function isValidRepoUrl(url) {
|
|
50
|
+
if (!url || typeof url !== 'string') return false;
|
|
51
|
+
return REPO_URL_RE.test(url);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a unix username
|
|
56
|
+
* @param {string} username
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isValidUsername(username) {
|
|
60
|
+
if (!username || typeof username !== 'string') return false;
|
|
61
|
+
if (username.length > 32) return false;
|
|
62
|
+
return USERNAME_RE.test(username);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate a project name (used in file paths)
|
|
67
|
+
* @param {string} name
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
function isValidProjectName(name) {
|
|
71
|
+
if (!name || typeof name !== 'string') return false;
|
|
72
|
+
if (name.length > 128) return false;
|
|
73
|
+
if (name.includes('..') || name.includes('/')) return false;
|
|
74
|
+
return PROJECT_NAME_RE.test(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
isValidDomain,
|
|
79
|
+
isValidBranch,
|
|
80
|
+
isValidRepoUrl,
|
|
81
|
+
isValidUsername,
|
|
82
|
+
isValidProjectName,
|
|
83
|
+
DOMAIN_RE,
|
|
84
|
+
BRANCH_RE,
|
|
85
|
+
REPO_URL_RE,
|
|
86
|
+
};
|