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