tide-commander 0.52.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/LICENSE +21 -0
- package/README.md +364 -0
- package/dist/assets/characters/Textures/colormap.png +0 -0
- package/dist/assets/characters/character-female-a.glb +0 -0
- package/dist/assets/characters/character-female-b.glb +0 -0
- package/dist/assets/characters/character-female-c.glb +0 -0
- package/dist/assets/characters/character-female-d.glb +0 -0
- package/dist/assets/characters/character-female-e.glb +0 -0
- package/dist/assets/characters/character-female-f.glb +0 -0
- package/dist/assets/characters/character-male-a-processed.gltf +11862 -0
- package/dist/assets/characters/character-male-a.glb +0 -0
- package/dist/assets/characters/character-male-b.glb +0 -0
- package/dist/assets/characters/character-male-c.glb +0 -0
- package/dist/assets/characters/character-male-d.glb +0 -0
- package/dist/assets/characters/character-male-e.glb +0 -0
- package/dist/assets/characters/character-male-f.glb +0 -0
- package/dist/assets/icons/icon-192.png +0 -0
- package/dist/assets/icons/icon-512.png +0 -0
- package/dist/assets/landing-Cc0MDBAK.css +1 -0
- package/dist/assets/main-BIpLsrUu.css +1 -0
- package/dist/assets/main-DMTRw3br.js +276 -0
- package/dist/assets/textures/concrete_floor_worn_001_diff_1k.jpg +0 -0
- package/dist/assets/textures/logo-blanco.png +0 -0
- package/dist/assets/vendor-react-uS-d4TUT.js +17 -0
- package/dist/assets/vendor-three-4iQNXcoo.js +3828 -0
- package/dist/assets/web-BZdi2lG9.js +1 -0
- package/dist/assets/web-yHsOO1Qb.js +1 -0
- package/dist/index.html +38 -0
- package/dist/manifest.json +39 -0
- package/dist/src/packages/landing/index.html +463 -0
- package/dist/src/packages/server/app.js +87 -0
- package/dist/src/packages/server/auth/index.js +121 -0
- package/dist/src/packages/server/claude/backend.js +578 -0
- package/dist/src/packages/server/claude/index.js +8 -0
- package/dist/src/packages/server/claude/runner/internal-events.js +22 -0
- package/dist/src/packages/server/claude/runner/process-lifecycle.js +208 -0
- package/dist/src/packages/server/claude/runner/recovery-store.js +72 -0
- package/dist/src/packages/server/claude/runner/resource-monitor.js +51 -0
- package/dist/src/packages/server/claude/runner/restart-policy.js +69 -0
- package/dist/src/packages/server/claude/runner/stdout-pipeline.js +153 -0
- package/dist/src/packages/server/claude/runner/watchdog.js +114 -0
- package/dist/src/packages/server/claude/runner.js +310 -0
- package/dist/src/packages/server/claude/session-loader.js +898 -0
- package/dist/src/packages/server/claude/types.js +5 -0
- package/dist/src/packages/server/cli.js +113 -0
- package/dist/src/packages/server/codex/backend.js +119 -0
- package/dist/src/packages/server/codex/index.js +2 -0
- package/dist/src/packages/server/codex/json-event-parser.js +612 -0
- package/dist/src/packages/server/data/builtin-skills/bitbucket-pr.js +298 -0
- package/dist/src/packages/server/data/builtin-skills/full-notifications.js +49 -0
- package/dist/src/packages/server/data/builtin-skills/git-captain.js +304 -0
- package/dist/src/packages/server/data/builtin-skills/index.js +61 -0
- package/dist/src/packages/server/data/builtin-skills/pm2-logs.js +354 -0
- package/dist/src/packages/server/data/builtin-skills/send-message-to-agent.js +51 -0
- package/dist/src/packages/server/data/builtin-skills/server-logs.js +124 -0
- package/dist/src/packages/server/data/builtin-skills/streaming-exec.js +94 -0
- package/dist/src/packages/server/data/builtin-skills/types.js +4 -0
- package/dist/src/packages/server/data/builtin-skills.js +6 -0
- package/dist/src/packages/server/data/index.js +890 -0
- package/dist/src/packages/server/data/snapshots.js +371 -0
- package/dist/src/packages/server/index.js +96 -0
- package/dist/src/packages/server/prompts/tide-commander.js +13 -0
- package/dist/src/packages/server/routes/agents.js +406 -0
- package/dist/src/packages/server/routes/config.js +347 -0
- package/dist/src/packages/server/routes/custom-models.js +170 -0
- package/dist/src/packages/server/routes/exec.js +269 -0
- package/dist/src/packages/server/routes/files.js +995 -0
- package/dist/src/packages/server/routes/index.js +38 -0
- package/dist/src/packages/server/routes/notifications.js +81 -0
- package/dist/src/packages/server/routes/permissions.js +115 -0
- package/dist/src/packages/server/routes/snapshots.js +224 -0
- package/dist/src/packages/server/routes/stt.js +99 -0
- package/dist/src/packages/server/routes/tts.js +166 -0
- package/dist/src/packages/server/routes/voice-assistant.js +310 -0
- package/dist/src/packages/server/runtime/claude-runtime-provider.js +10 -0
- package/dist/src/packages/server/runtime/codex-runtime-provider.js +11 -0
- package/dist/src/packages/server/runtime/index.js +2 -0
- package/dist/src/packages/server/runtime/types.js +6 -0
- package/dist/src/packages/server/services/agent-lifecycle-service.js +82 -0
- package/dist/src/packages/server/services/agent-service.js +410 -0
- package/dist/src/packages/server/services/boss-message-service.js +430 -0
- package/dist/src/packages/server/services/boss-service.js +553 -0
- package/dist/src/packages/server/services/building-service.js +867 -0
- package/dist/src/packages/server/services/claude-service.js +5 -0
- package/dist/src/packages/server/services/custom-class-service.js +323 -0
- package/dist/src/packages/server/services/database-service.js +914 -0
- package/dist/src/packages/server/services/docker-service.js +865 -0
- package/dist/src/packages/server/services/fileTracker.js +242 -0
- package/dist/src/packages/server/services/index.js +21 -0
- package/dist/src/packages/server/services/permission-service.js +258 -0
- package/dist/src/packages/server/services/pm2-service.js +435 -0
- package/dist/src/packages/server/services/runtime-command-execution.js +168 -0
- package/dist/src/packages/server/services/runtime-events.js +357 -0
- package/dist/src/packages/server/services/runtime-service.js +308 -0
- package/dist/src/packages/server/services/runtime-status-sync.js +104 -0
- package/dist/src/packages/server/services/runtime-subagents.js +50 -0
- package/dist/src/packages/server/services/runtime-watchdog.js +74 -0
- package/dist/src/packages/server/services/secrets-service.js +206 -0
- package/dist/src/packages/server/services/skill-service.js +508 -0
- package/dist/src/packages/server/services/subordinate-context-service.js +223 -0
- package/dist/src/packages/server/services/supervisor-claude.js +132 -0
- package/dist/src/packages/server/services/supervisor-prompts.js +80 -0
- package/dist/src/packages/server/services/supervisor-service.js +659 -0
- package/dist/src/packages/server/services/work-plan-service.js +476 -0
- package/dist/src/packages/server/setup.js +86 -0
- package/dist/src/packages/server/utils/index.js +4 -0
- package/dist/src/packages/server/utils/logger.js +302 -0
- package/dist/src/packages/server/utils/string.js +39 -0
- package/dist/src/packages/server/utils/tool-formatting.js +139 -0
- package/dist/src/packages/server/utils/unicode.js +46 -0
- package/dist/src/packages/server/websocket/handler.js +290 -0
- package/dist/src/packages/server/websocket/handlers/agent-handler.js +515 -0
- package/dist/src/packages/server/websocket/handlers/boss-handler.js +116 -0
- package/dist/src/packages/server/websocket/handlers/boss-response-handler.js +250 -0
- package/dist/src/packages/server/websocket/handlers/building-handler.js +298 -0
- package/dist/src/packages/server/websocket/handlers/command-handler.js +217 -0
- package/dist/src/packages/server/websocket/handlers/custom-class-handler.js +68 -0
- package/dist/src/packages/server/websocket/handlers/database-handler.js +223 -0
- package/dist/src/packages/server/websocket/handlers/notification-handler.js +25 -0
- package/dist/src/packages/server/websocket/handlers/permission-handler.js +21 -0
- package/dist/src/packages/server/websocket/handlers/secrets-handler.js +61 -0
- package/dist/src/packages/server/websocket/handlers/skill-handler.js +148 -0
- package/dist/src/packages/server/websocket/handlers/supervisor-handler.js +44 -0
- package/dist/src/packages/server/websocket/handlers/sync-handler.js +19 -0
- package/dist/src/packages/server/websocket/handlers/types.js +4 -0
- package/dist/src/packages/server/websocket/listeners/boss-listeners.js +21 -0
- package/dist/src/packages/server/websocket/listeners/index.js +32 -0
- package/dist/src/packages/server/websocket/listeners/permission-listeners.js +19 -0
- package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +196 -0
- package/dist/src/packages/server/websocket/listeners/skill-listeners.js +51 -0
- package/dist/src/packages/server/websocket/listeners/supervisor-listeners.js +37 -0
- package/dist/src/packages/shared/agent-types.js +54 -0
- package/dist/src/packages/shared/building-types.js +43 -0
- package/dist/src/packages/shared/common-types.js +1 -0
- package/dist/src/packages/shared/database-types.js +8 -0
- package/dist/src/packages/shared/types/snapshot.js +7 -0
- package/dist/src/packages/shared/types.js +12 -0
- package/dist/src/packages/shared/websocket-messages.js +1 -0
- package/dist/sw.js +37 -0
- package/package.json +90 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Service - Wrapper for Docker CLI commands
|
|
3
|
+
*
|
|
4
|
+
* Provides container and compose management for buildings.
|
|
5
|
+
* Uses CLI commands instead of Docker API for simplicity and
|
|
6
|
+
* to support users who have Docker installed via various methods.
|
|
7
|
+
*/
|
|
8
|
+
import { exec, spawn } from 'child_process';
|
|
9
|
+
import { promisify } from 'util';
|
|
10
|
+
import { createLogger } from '../utils/index.js';
|
|
11
|
+
// Track active log streams by building ID
|
|
12
|
+
const activeLogStreams = new Map();
|
|
13
|
+
// Track stream generation to prevent duplicate initial logs
|
|
14
|
+
// When a new stream starts, its generation is incremented and old streams' chunks are ignored
|
|
15
|
+
const streamGenerations = new Map();
|
|
16
|
+
const execAsync = promisify(exec);
|
|
17
|
+
const log = createLogger('DockerService');
|
|
18
|
+
/**
|
|
19
|
+
* Sanitize container name for Docker (alphanumeric, dash, underscore only)
|
|
20
|
+
* Prefixes with "tc-" to identify Tide Commander managed containers
|
|
21
|
+
*/
|
|
22
|
+
export function sanitizeContainerName(name, id) {
|
|
23
|
+
const sanitized = name.toLowerCase().replace(/[^a-z0-9-_]/g, '-').substring(0, 50);
|
|
24
|
+
// Use the last 8 characters of the ID to ensure uniqueness
|
|
25
|
+
const idSuffix = id.slice(-8);
|
|
26
|
+
return `tc-${sanitized}-${idSuffix}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get the Docker container name for a building
|
|
30
|
+
*/
|
|
31
|
+
export function getContainerName(building) {
|
|
32
|
+
return building.docker?.containerName || sanitizeContainerName(building.name, building.id);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get the Docker compose project name for a building
|
|
36
|
+
*/
|
|
37
|
+
export function getComposeProjectName(building) {
|
|
38
|
+
return building.docker?.composeProject || sanitizeContainerName(building.name, building.id);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if Docker is installed and available
|
|
42
|
+
*/
|
|
43
|
+
export async function isDockerAvailable() {
|
|
44
|
+
try {
|
|
45
|
+
await execAsync('docker --version');
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check if Docker Compose is available
|
|
54
|
+
*/
|
|
55
|
+
export async function isComposeAvailable() {
|
|
56
|
+
try {
|
|
57
|
+
// Try new compose plugin first
|
|
58
|
+
await execAsync('docker compose version');
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
try {
|
|
63
|
+
// Fall back to standalone docker-compose
|
|
64
|
+
await execAsync('docker-compose --version');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get the compose command (docker compose or docker-compose)
|
|
74
|
+
*/
|
|
75
|
+
async function getComposeCommand() {
|
|
76
|
+
try {
|
|
77
|
+
await execAsync('docker compose version');
|
|
78
|
+
return 'docker compose';
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return 'docker-compose';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parse Docker container status string to our type
|
|
86
|
+
*/
|
|
87
|
+
function parseContainerStatus(status) {
|
|
88
|
+
const statusLower = status.toLowerCase();
|
|
89
|
+
if (statusLower.includes('running'))
|
|
90
|
+
return 'running';
|
|
91
|
+
if (statusLower.includes('created'))
|
|
92
|
+
return 'created';
|
|
93
|
+
if (statusLower.includes('exited'))
|
|
94
|
+
return 'exited';
|
|
95
|
+
if (statusLower.includes('paused'))
|
|
96
|
+
return 'paused';
|
|
97
|
+
if (statusLower.includes('restarting'))
|
|
98
|
+
return 'restarting';
|
|
99
|
+
if (statusLower.includes('removing'))
|
|
100
|
+
return 'removing';
|
|
101
|
+
if (statusLower.includes('dead'))
|
|
102
|
+
return 'dead';
|
|
103
|
+
return 'exited';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Parse Docker health status
|
|
107
|
+
*/
|
|
108
|
+
function parseHealthStatus(health) {
|
|
109
|
+
if (!health)
|
|
110
|
+
return 'none';
|
|
111
|
+
const healthLower = health.toLowerCase();
|
|
112
|
+
if (healthLower.includes('healthy'))
|
|
113
|
+
return 'healthy';
|
|
114
|
+
if (healthLower.includes('unhealthy'))
|
|
115
|
+
return 'unhealthy';
|
|
116
|
+
if (healthLower.includes('starting'))
|
|
117
|
+
return 'starting';
|
|
118
|
+
return 'none';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Parse port mapping string (e.g., "0.0.0.0:8080->80/tcp") to DockerPortMapping
|
|
122
|
+
*/
|
|
123
|
+
function _parsePortMapping(portStr) {
|
|
124
|
+
// Format: 0.0.0.0:8080->80/tcp or :::8080->80/tcp or 8080->80/tcp
|
|
125
|
+
const match = portStr.match(/(?:[\d.]+:|:::)?(\d+)->(\d+)\/(tcp|udp)/);
|
|
126
|
+
if (match) {
|
|
127
|
+
return {
|
|
128
|
+
host: parseInt(match[1], 10),
|
|
129
|
+
container: parseInt(match[2], 10),
|
|
130
|
+
protocol: match[3],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Start a Docker container for a building
|
|
137
|
+
* For "existing" mode, just starts the already-existing container
|
|
138
|
+
*/
|
|
139
|
+
export async function startContainer(building) {
|
|
140
|
+
if (!building.docker?.enabled || (building.docker.mode !== 'container' && building.docker.mode !== 'existing')) {
|
|
141
|
+
return { success: false, error: 'Docker container mode not configured for this building' };
|
|
142
|
+
}
|
|
143
|
+
// For existing containers, just start them (don't recreate)
|
|
144
|
+
if (building.docker.mode === 'existing') {
|
|
145
|
+
const containerName = getContainerName(building);
|
|
146
|
+
try {
|
|
147
|
+
log.log(`Starting existing container: ${containerName}`);
|
|
148
|
+
await execAsync(`docker start "${containerName}"`, { timeout: 30000 });
|
|
149
|
+
return { success: true };
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
log.error(`Failed to start existing container: ${error.message}`);
|
|
153
|
+
return { success: false, error: error.message };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const { image, ports, volumes, env, network, command, restart, pull } = building.docker;
|
|
157
|
+
const containerName = getContainerName(building);
|
|
158
|
+
const cwd = building.cwd || process.cwd();
|
|
159
|
+
if (!image) {
|
|
160
|
+
return { success: false, error: 'Docker image not specified' };
|
|
161
|
+
}
|
|
162
|
+
// Remove any existing container with this name
|
|
163
|
+
try {
|
|
164
|
+
await execAsync(`docker rm -f "${containerName}"`, { timeout: 30000 });
|
|
165
|
+
log.log(`Removed existing container: ${containerName}`);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Ignore - container might not exist
|
|
169
|
+
}
|
|
170
|
+
// Pull image if needed
|
|
171
|
+
if (pull === 'always') {
|
|
172
|
+
try {
|
|
173
|
+
log.log(`Pulling image: ${image}`);
|
|
174
|
+
await execAsync(`docker pull "${image}"`, { timeout: 300000 }); // 5 min timeout
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
log.error(`Failed to pull image: ${error.message}`);
|
|
178
|
+
// Continue anyway - image might be available locally
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Build docker run command
|
|
182
|
+
const parts = ['docker', 'run', '-d'];
|
|
183
|
+
// Container name
|
|
184
|
+
parts.push('--name', `"${containerName}"`);
|
|
185
|
+
// Restart policy
|
|
186
|
+
if (restart && restart !== 'no') {
|
|
187
|
+
parts.push('--restart', restart);
|
|
188
|
+
}
|
|
189
|
+
// Port mappings
|
|
190
|
+
if (ports && ports.length > 0) {
|
|
191
|
+
for (const port of ports) {
|
|
192
|
+
parts.push('-p', port);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Volume mounts
|
|
196
|
+
if (volumes && volumes.length > 0) {
|
|
197
|
+
for (const volume of volumes) {
|
|
198
|
+
// Handle relative paths by resolving against cwd
|
|
199
|
+
let volumeMapping = volume;
|
|
200
|
+
if (volume.includes(':')) {
|
|
201
|
+
const [hostPath, ...rest] = volume.split(':');
|
|
202
|
+
if (hostPath && !hostPath.startsWith('/') && !hostPath.startsWith('~')) {
|
|
203
|
+
// Relative path - resolve against cwd
|
|
204
|
+
const resolvedPath = `${cwd}/${hostPath}`;
|
|
205
|
+
volumeMapping = `${resolvedPath}:${rest.join(':')}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
parts.push('-v', `"${volumeMapping}"`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Environment variables
|
|
212
|
+
if (env && Object.keys(env).length > 0) {
|
|
213
|
+
for (const [key, value] of Object.entries(env)) {
|
|
214
|
+
parts.push('-e', `"${key}=${value}"`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Network
|
|
218
|
+
if (network) {
|
|
219
|
+
parts.push('--network', network);
|
|
220
|
+
}
|
|
221
|
+
// Image
|
|
222
|
+
parts.push(`"${image}"`);
|
|
223
|
+
// Command override
|
|
224
|
+
if (command) {
|
|
225
|
+
parts.push(command);
|
|
226
|
+
}
|
|
227
|
+
const cmd = parts.join(' ');
|
|
228
|
+
try {
|
|
229
|
+
log.log(`Starting Docker container: ${cmd}`);
|
|
230
|
+
const { stdout, stderr } = await execAsync(cmd, { timeout: 60000, cwd });
|
|
231
|
+
log.log(`Docker run output: ${stdout.trim()}`);
|
|
232
|
+
if (stderr)
|
|
233
|
+
log.log(`Docker run stderr: ${stderr}`);
|
|
234
|
+
return { success: true };
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
log.error(`Docker run failed: ${error.message}`);
|
|
238
|
+
return { success: false, error: error.message };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Stop a Docker container
|
|
243
|
+
* Works for both managed and existing containers
|
|
244
|
+
*/
|
|
245
|
+
export async function stopContainer(building) {
|
|
246
|
+
const containerName = getContainerName(building);
|
|
247
|
+
try {
|
|
248
|
+
log.log(`Stopping Docker container: ${containerName}`);
|
|
249
|
+
await execAsync(`docker stop "${containerName}"`, { timeout: 30000 });
|
|
250
|
+
return { success: true };
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
// If container not found, consider it already stopped
|
|
254
|
+
if (error.message.includes('No such container') || error.message.includes('not found')) {
|
|
255
|
+
return { success: true };
|
|
256
|
+
}
|
|
257
|
+
log.error(`Docker stop failed: ${error.message}`);
|
|
258
|
+
return { success: false, error: error.message };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Restart a Docker container
|
|
263
|
+
* For existing containers, uses docker restart instead of recreating
|
|
264
|
+
*/
|
|
265
|
+
export async function restartContainer(building) {
|
|
266
|
+
// For existing containers, just restart (don't recreate)
|
|
267
|
+
if (building.docker?.mode === 'existing') {
|
|
268
|
+
const containerName = getContainerName(building);
|
|
269
|
+
try {
|
|
270
|
+
log.log(`Restarting existing container: ${containerName}`);
|
|
271
|
+
await execAsync(`docker restart "${containerName}"`, { timeout: 60000 });
|
|
272
|
+
return { success: true };
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
log.error(`Failed to restart existing container: ${error.message}`);
|
|
276
|
+
return { success: false, error: error.message };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// For managed containers, use remove+start to ensure config changes are applied
|
|
280
|
+
return startContainer(building);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Remove a Docker container (cleanup)
|
|
284
|
+
* For "existing" containers, we don't remove them - just detach from monitoring
|
|
285
|
+
*/
|
|
286
|
+
export async function removeContainer(building) {
|
|
287
|
+
// For existing containers, don't actually remove them
|
|
288
|
+
if (building.docker?.mode === 'existing') {
|
|
289
|
+
log.log(`Detaching from existing container (not removing): ${getContainerName(building)}`);
|
|
290
|
+
return { success: true };
|
|
291
|
+
}
|
|
292
|
+
const containerName = getContainerName(building);
|
|
293
|
+
try {
|
|
294
|
+
log.log(`Removing Docker container: ${containerName}`);
|
|
295
|
+
await execAsync(`docker rm -f "${containerName}"`, { timeout: 30000 });
|
|
296
|
+
return { success: true };
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
// Ignore "not found" errors
|
|
300
|
+
if (error.message.includes('No such container') || error.message.includes('not found')) {
|
|
301
|
+
return { success: true };
|
|
302
|
+
}
|
|
303
|
+
log.error(`Docker remove failed: ${error.message}`);
|
|
304
|
+
return { success: false, error: error.message };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// ============================================================================
|
|
308
|
+
// Docker Compose Operations
|
|
309
|
+
// ============================================================================
|
|
310
|
+
/**
|
|
311
|
+
* Start services with docker compose
|
|
312
|
+
*/
|
|
313
|
+
export async function composeUp(building) {
|
|
314
|
+
if (!building.docker?.enabled || building.docker.mode !== 'compose') {
|
|
315
|
+
return { success: false, error: 'Docker compose mode not configured for this building' };
|
|
316
|
+
}
|
|
317
|
+
const { composePath, services, pull } = building.docker;
|
|
318
|
+
const cwd = building.cwd || process.cwd();
|
|
319
|
+
const projectName = getComposeProjectName(building);
|
|
320
|
+
const composeCmd = await getComposeCommand();
|
|
321
|
+
const composeFile = composePath || 'docker-compose.yml';
|
|
322
|
+
// Build command
|
|
323
|
+
const parts = [composeCmd, '-p', `"${projectName}"`, '-f', `"${composeFile}"`];
|
|
324
|
+
// Pull if needed
|
|
325
|
+
if (pull === 'always') {
|
|
326
|
+
try {
|
|
327
|
+
log.log(`Pulling compose images for project: ${projectName}`);
|
|
328
|
+
await execAsync(`${parts.join(' ')} pull`, { timeout: 300000, cwd });
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
log.error(`Failed to pull compose images: ${error.message}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Add up command
|
|
335
|
+
parts.push('up', '-d');
|
|
336
|
+
// Specific services if specified
|
|
337
|
+
if (services && services.length > 0) {
|
|
338
|
+
parts.push(...services);
|
|
339
|
+
}
|
|
340
|
+
const cmd = parts.join(' ');
|
|
341
|
+
try {
|
|
342
|
+
log.log(`Starting Docker Compose: ${cmd}`);
|
|
343
|
+
const { stdout, stderr } = await execAsync(cmd, { timeout: 120000, cwd });
|
|
344
|
+
log.log(`Compose up output: ${stdout}`);
|
|
345
|
+
if (stderr)
|
|
346
|
+
log.log(`Compose up stderr: ${stderr}`);
|
|
347
|
+
return { success: true };
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
log.error(`Compose up failed: ${error.message}`);
|
|
351
|
+
return { success: false, error: error.message };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Stop services with docker compose
|
|
356
|
+
*/
|
|
357
|
+
export async function composeDown(building) {
|
|
358
|
+
if (!building.docker?.enabled || building.docker.mode !== 'compose') {
|
|
359
|
+
return { success: false, error: 'Docker compose mode not configured for this building' };
|
|
360
|
+
}
|
|
361
|
+
const { composePath, services } = building.docker;
|
|
362
|
+
const cwd = building.cwd || process.cwd();
|
|
363
|
+
const projectName = getComposeProjectName(building);
|
|
364
|
+
const composeCmd = await getComposeCommand();
|
|
365
|
+
const composeFile = composePath || 'docker-compose.yml';
|
|
366
|
+
// Build command
|
|
367
|
+
const parts = [composeCmd, '-p', `"${projectName}"`, '-f', `"${composeFile}"`];
|
|
368
|
+
// If specific services, use stop instead of down
|
|
369
|
+
if (services && services.length > 0) {
|
|
370
|
+
parts.push('stop', ...services);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
parts.push('down');
|
|
374
|
+
}
|
|
375
|
+
const cmd = parts.join(' ');
|
|
376
|
+
try {
|
|
377
|
+
log.log(`Stopping Docker Compose: ${cmd}`);
|
|
378
|
+
await execAsync(cmd, { timeout: 60000, cwd });
|
|
379
|
+
return { success: true };
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
log.error(`Compose down failed: ${error.message}`);
|
|
383
|
+
return { success: false, error: error.message };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Restart services with docker compose
|
|
388
|
+
*/
|
|
389
|
+
export async function composeRestart(building) {
|
|
390
|
+
// Use down+up to ensure config changes are applied
|
|
391
|
+
const downResult = await composeDown(building);
|
|
392
|
+
if (!downResult.success) {
|
|
393
|
+
// Try to continue with up anyway
|
|
394
|
+
log.log(`Compose down failed, attempting up anyway: ${downResult.error}`);
|
|
395
|
+
}
|
|
396
|
+
return composeUp(building);
|
|
397
|
+
}
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Status and Monitoring
|
|
400
|
+
// ============================================================================
|
|
401
|
+
/**
|
|
402
|
+
* Get status of a single Docker container
|
|
403
|
+
*/
|
|
404
|
+
export async function getContainerStatus(building) {
|
|
405
|
+
const containerName = getContainerName(building);
|
|
406
|
+
try {
|
|
407
|
+
// Get container info in JSON format
|
|
408
|
+
const { stdout } = await execAsync(`docker inspect "${containerName}" --format '{{json .}}'`, { timeout: 10000 });
|
|
409
|
+
const info = JSON.parse(stdout);
|
|
410
|
+
// Parse ports
|
|
411
|
+
const ports = [];
|
|
412
|
+
const portBindings = info.NetworkSettings?.Ports || {};
|
|
413
|
+
for (const [containerPort, bindings] of Object.entries(portBindings)) {
|
|
414
|
+
if (bindings && Array.isArray(bindings)) {
|
|
415
|
+
for (const binding of bindings) {
|
|
416
|
+
const [port, protocol] = containerPort.split('/');
|
|
417
|
+
ports.push({
|
|
418
|
+
host: parseInt(binding.HostPort, 10),
|
|
419
|
+
container: parseInt(port, 10),
|
|
420
|
+
protocol: (protocol || 'tcp'),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Get stats for CPU and memory
|
|
426
|
+
let cpu = 0;
|
|
427
|
+
let memory = 0;
|
|
428
|
+
let memoryLimit = 0;
|
|
429
|
+
try {
|
|
430
|
+
const { stdout: statsOutput } = await execAsync(`docker stats "${containerName}" --no-stream --format "{{.CPUPerc}},{{.MemUsage}}"`, { timeout: 10000 });
|
|
431
|
+
const statsLine = statsOutput.trim();
|
|
432
|
+
if (statsLine) {
|
|
433
|
+
const [cpuStr, memStr] = statsLine.split(',');
|
|
434
|
+
cpu = parseFloat(cpuStr.replace('%', '')) || 0;
|
|
435
|
+
// Parse memory like "128MiB / 1GiB"
|
|
436
|
+
const memMatch = memStr?.match(/([\d.]+)(\w+)\s*\/\s*([\d.]+)(\w+)/);
|
|
437
|
+
if (memMatch) {
|
|
438
|
+
const usedVal = parseFloat(memMatch[1]);
|
|
439
|
+
const usedUnit = memMatch[2].toLowerCase();
|
|
440
|
+
const limitVal = parseFloat(memMatch[3]);
|
|
441
|
+
const limitUnit = memMatch[4].toLowerCase();
|
|
442
|
+
const unitMultiplier = (unit) => {
|
|
443
|
+
if (unit.includes('gib') || unit.includes('gb'))
|
|
444
|
+
return 1024 * 1024 * 1024;
|
|
445
|
+
if (unit.includes('mib') || unit.includes('mb'))
|
|
446
|
+
return 1024 * 1024;
|
|
447
|
+
if (unit.includes('kib') || unit.includes('kb'))
|
|
448
|
+
return 1024;
|
|
449
|
+
return 1;
|
|
450
|
+
};
|
|
451
|
+
memory = usedVal * unitMultiplier(usedUnit);
|
|
452
|
+
memoryLimit = limitVal * unitMultiplier(limitUnit);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// Stats might fail if container is not running
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
containerId: info.Id?.substring(0, 12),
|
|
461
|
+
containerName: info.Name?.replace(/^\//, ''),
|
|
462
|
+
image: info.Config?.Image,
|
|
463
|
+
status: parseContainerStatus(info.State?.Status || 'unknown'),
|
|
464
|
+
health: parseHealthStatus(info.State?.Health?.Status),
|
|
465
|
+
cpu,
|
|
466
|
+
memory,
|
|
467
|
+
memoryLimit,
|
|
468
|
+
ports,
|
|
469
|
+
createdAt: info.Created ? new Date(info.Created).getTime() : undefined,
|
|
470
|
+
startedAt: info.State?.StartedAt ? new Date(info.State.StartedAt).getTime() : undefined,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
// Container might not exist
|
|
475
|
+
if (!error.message.includes('No such object') && !error.message.includes('not found')) {
|
|
476
|
+
log.error(`Docker inspect failed: ${error.message}`);
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Get status of all Docker Compose services for a building
|
|
483
|
+
*/
|
|
484
|
+
export async function getComposeStatus(building) {
|
|
485
|
+
if (!building.docker?.enabled || building.docker.mode !== 'compose') {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const { composePath } = building.docker;
|
|
489
|
+
const cwd = building.cwd || process.cwd();
|
|
490
|
+
const projectName = getComposeProjectName(building);
|
|
491
|
+
const composeCmd = await getComposeCommand();
|
|
492
|
+
const composeFile = composePath || 'docker-compose.yml';
|
|
493
|
+
try {
|
|
494
|
+
// Get compose services status
|
|
495
|
+
const { stdout } = await execAsync(`${composeCmd} -p "${projectName}" -f "${composeFile}" ps --format json`, { timeout: 10000, cwd });
|
|
496
|
+
const services = [];
|
|
497
|
+
let overallStatus = 'exited';
|
|
498
|
+
let hasRunning = false;
|
|
499
|
+
let hasError = false;
|
|
500
|
+
// Parse JSON output (one JSON object per line)
|
|
501
|
+
const lines = stdout.trim().split('\n').filter(line => line.trim());
|
|
502
|
+
for (const line of lines) {
|
|
503
|
+
try {
|
|
504
|
+
const svc = JSON.parse(line);
|
|
505
|
+
const status = parseContainerStatus(svc.State || svc.Status || 'unknown');
|
|
506
|
+
const health = parseHealthStatus(svc.Health);
|
|
507
|
+
services.push({
|
|
508
|
+
name: svc.Service || svc.Name,
|
|
509
|
+
status,
|
|
510
|
+
health,
|
|
511
|
+
containerId: svc.ID?.substring(0, 12),
|
|
512
|
+
});
|
|
513
|
+
if (status === 'running')
|
|
514
|
+
hasRunning = true;
|
|
515
|
+
if (status === 'dead' || health === 'unhealthy')
|
|
516
|
+
hasError = true;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// Skip invalid JSON lines
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Determine overall status
|
|
523
|
+
if (hasError)
|
|
524
|
+
overallStatus = 'dead';
|
|
525
|
+
else if (hasRunning)
|
|
526
|
+
overallStatus = 'running';
|
|
527
|
+
else if (services.length > 0)
|
|
528
|
+
overallStatus = 'exited';
|
|
529
|
+
return {
|
|
530
|
+
status: overallStatus,
|
|
531
|
+
services,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
log.error(`Compose ps failed: ${error.message}`);
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Get status for a building (dispatches to container or compose based on mode)
|
|
541
|
+
*/
|
|
542
|
+
export async function getStatus(building) {
|
|
543
|
+
if (!building.docker?.enabled)
|
|
544
|
+
return null;
|
|
545
|
+
if (building.docker.mode === 'compose') {
|
|
546
|
+
return getComposeStatus(building);
|
|
547
|
+
}
|
|
548
|
+
return getContainerStatus(building);
|
|
549
|
+
}
|
|
550
|
+
// ============================================================================
|
|
551
|
+
// Container Discovery
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// Use the shared type for consistency
|
|
554
|
+
// ExistingDockerContainer is imported from shared/types.ts
|
|
555
|
+
/**
|
|
556
|
+
* List all Docker containers on the system (both running and stopped)
|
|
557
|
+
* Used to allow users to adopt existing containers into Tide Commander
|
|
558
|
+
*/
|
|
559
|
+
export async function listAllContainers() {
|
|
560
|
+
try {
|
|
561
|
+
const { stdout } = await execAsync(`docker ps -a --format '{{json .}}'`, { timeout: 10000 });
|
|
562
|
+
const containers = [];
|
|
563
|
+
const lines = stdout.trim().split('\n').filter(line => line.trim());
|
|
564
|
+
for (const line of lines) {
|
|
565
|
+
try {
|
|
566
|
+
const info = JSON.parse(line);
|
|
567
|
+
// Parse ports from the Ports field (e.g., "0.0.0.0:8080->80/tcp, 443/tcp")
|
|
568
|
+
const ports = [];
|
|
569
|
+
if (info.Ports) {
|
|
570
|
+
const portMatches = info.Ports.matchAll(/(?:[\d.]+:|:::)?(\d+)->(\d+)\/(tcp|udp)/g);
|
|
571
|
+
for (const match of portMatches) {
|
|
572
|
+
ports.push({
|
|
573
|
+
host: parseInt(match[1], 10),
|
|
574
|
+
container: parseInt(match[2], 10),
|
|
575
|
+
protocol: match[3],
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
containers.push({
|
|
580
|
+
id: info.ID,
|
|
581
|
+
name: info.Names.replace(/^\//, ''),
|
|
582
|
+
image: info.Image,
|
|
583
|
+
status: parseContainerStatus(info.State || info.Status),
|
|
584
|
+
ports,
|
|
585
|
+
created: info.CreatedAt || info.Created,
|
|
586
|
+
state: info.State || '',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// Skip invalid JSON lines
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return containers;
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
log.error(`Failed to list containers: ${error.message}`);
|
|
597
|
+
return [];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* List all Docker Compose projects on the system
|
|
602
|
+
*/
|
|
603
|
+
export async function listComposeProjects() {
|
|
604
|
+
const composeCmd = await getComposeCommand();
|
|
605
|
+
try {
|
|
606
|
+
const { stdout } = await execAsync(`${composeCmd} ls --format json`, { timeout: 10000 });
|
|
607
|
+
const projects = [];
|
|
608
|
+
// docker compose ls returns a JSON array
|
|
609
|
+
try {
|
|
610
|
+
const parsed = JSON.parse(stdout);
|
|
611
|
+
if (Array.isArray(parsed)) {
|
|
612
|
+
for (const proj of parsed) {
|
|
613
|
+
projects.push({
|
|
614
|
+
name: proj.Name,
|
|
615
|
+
status: proj.Status,
|
|
616
|
+
configFiles: proj.ConfigFiles,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// Fallback: parse line by line if not valid JSON array
|
|
623
|
+
const lines = stdout.trim().split('\n').filter(line => line.trim());
|
|
624
|
+
for (const line of lines) {
|
|
625
|
+
try {
|
|
626
|
+
const proj = JSON.parse(line);
|
|
627
|
+
projects.push({
|
|
628
|
+
name: proj.Name,
|
|
629
|
+
status: proj.Status,
|
|
630
|
+
configFiles: proj.ConfigFiles,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
// Skip invalid lines
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return projects;
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
log.error(`Failed to list compose projects: ${error.message}`);
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Get status of all TC-managed Docker containers
|
|
647
|
+
* Returns a map of container name -> DockerStatus
|
|
648
|
+
*/
|
|
649
|
+
export async function getAllContainerStatus() {
|
|
650
|
+
const statusMap = new Map();
|
|
651
|
+
try {
|
|
652
|
+
// List all containers with tc- prefix
|
|
653
|
+
const { stdout } = await execAsync(`docker ps -a --filter "name=^tc-" --format "{{.Names}}"`, { timeout: 10000 });
|
|
654
|
+
const containerNames = stdout.trim().split('\n').filter(n => n.trim());
|
|
655
|
+
// Get status for each container in parallel
|
|
656
|
+
const statusPromises = containerNames.map(async (name) => {
|
|
657
|
+
try {
|
|
658
|
+
const { stdout: inspectOutput } = await execAsync(`docker inspect "${name}" --format '{{json .}}'`, { timeout: 10000 });
|
|
659
|
+
const info = JSON.parse(inspectOutput);
|
|
660
|
+
// Parse ports
|
|
661
|
+
const ports = [];
|
|
662
|
+
const portBindings = info.NetworkSettings?.Ports || {};
|
|
663
|
+
for (const [containerPort, bindings] of Object.entries(portBindings)) {
|
|
664
|
+
if (bindings && Array.isArray(bindings)) {
|
|
665
|
+
for (const binding of bindings) {
|
|
666
|
+
const [port, protocol] = containerPort.split('/');
|
|
667
|
+
ports.push({
|
|
668
|
+
host: parseInt(binding.HostPort, 10),
|
|
669
|
+
container: parseInt(port, 10),
|
|
670
|
+
protocol: (protocol || 'tcp'),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
name,
|
|
677
|
+
status: {
|
|
678
|
+
containerId: info.Id?.substring(0, 12),
|
|
679
|
+
containerName: info.Name?.replace(/^\//, ''),
|
|
680
|
+
image: info.Config?.Image,
|
|
681
|
+
status: parseContainerStatus(info.State?.Status || 'unknown'),
|
|
682
|
+
health: parseHealthStatus(info.State?.Health?.Status),
|
|
683
|
+
ports,
|
|
684
|
+
createdAt: info.Created ? new Date(info.Created).getTime() : undefined,
|
|
685
|
+
startedAt: info.State?.StartedAt ? new Date(info.State.StartedAt).getTime() : undefined,
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return { name, status: null };
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
const results = await Promise.all(statusPromises);
|
|
694
|
+
for (const { name, status } of results) {
|
|
695
|
+
if (status) {
|
|
696
|
+
statusMap.set(name, status);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
if (!error.message.includes('ENOENT')) {
|
|
702
|
+
log.error(`Docker status fetch failed: ${error.message}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return statusMap;
|
|
706
|
+
}
|
|
707
|
+
// ============================================================================
|
|
708
|
+
// Logs
|
|
709
|
+
// ============================================================================
|
|
710
|
+
/**
|
|
711
|
+
* Get logs from a Docker container
|
|
712
|
+
*/
|
|
713
|
+
export async function getLogs(building, lines = 100, service) {
|
|
714
|
+
if (!building.docker?.enabled) {
|
|
715
|
+
return 'Docker not configured for this building';
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
let cmd;
|
|
719
|
+
if (building.docker.mode === 'compose') {
|
|
720
|
+
const { composePath } = building.docker;
|
|
721
|
+
const cwd = building.cwd || process.cwd();
|
|
722
|
+
const projectName = getComposeProjectName(building);
|
|
723
|
+
const composeCmd = await getComposeCommand();
|
|
724
|
+
const composeFile = composePath || 'docker-compose.yml';
|
|
725
|
+
cmd = `cd "${cwd}" && ${composeCmd} -p "${projectName}" -f "${composeFile}" logs --tail ${lines}`;
|
|
726
|
+
if (service) {
|
|
727
|
+
cmd += ` ${service}`;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
const containerName = getContainerName(building);
|
|
732
|
+
cmd = `docker logs "${containerName}" --tail ${lines}`;
|
|
733
|
+
}
|
|
734
|
+
log.log(`Fetching Docker logs: ${cmd}`);
|
|
735
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
736
|
+
maxBuffer: 1024 * 1024 * 5, // 5MB
|
|
737
|
+
timeout: 30000,
|
|
738
|
+
});
|
|
739
|
+
// Docker logs outputs to stderr for actual log content
|
|
740
|
+
return stderr || stdout;
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
log.error(`Docker logs failed: ${error.message}`);
|
|
744
|
+
return `Error fetching logs: ${error.message}`;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Start streaming logs for a Docker container/compose in real-time
|
|
749
|
+
* Returns a function to stop the stream
|
|
750
|
+
*/
|
|
751
|
+
export async function startLogStream(building, callbacks, initialLines = 100, service) {
|
|
752
|
+
const buildingId = building.id;
|
|
753
|
+
if (!building.docker?.enabled) {
|
|
754
|
+
return { success: false, error: 'Docker not configured', stop: () => { } };
|
|
755
|
+
}
|
|
756
|
+
// Stop any existing stream for this building
|
|
757
|
+
stopLogStream(buildingId);
|
|
758
|
+
// Increment stream generation to invalidate any in-flight chunks from previous stream
|
|
759
|
+
const currentGeneration = (streamGenerations.get(buildingId) || 0) + 1;
|
|
760
|
+
streamGenerations.set(buildingId, currentGeneration);
|
|
761
|
+
try {
|
|
762
|
+
let child;
|
|
763
|
+
if (building.docker.mode === 'compose') {
|
|
764
|
+
const { composePath } = building.docker;
|
|
765
|
+
const cwd = building.cwd || process.cwd();
|
|
766
|
+
const projectName = getComposeProjectName(building);
|
|
767
|
+
const composeCmd = await getComposeCommand();
|
|
768
|
+
const composeFile = composePath || 'docker-compose.yml';
|
|
769
|
+
log.log(`Starting compose log stream for project: ${projectName} (gen ${currentGeneration})`);
|
|
770
|
+
const args = [
|
|
771
|
+
...composeCmd.split(' ').slice(1), // Handle 'docker compose' vs 'docker-compose'
|
|
772
|
+
'-p', projectName,
|
|
773
|
+
'-f', composeFile,
|
|
774
|
+
'logs',
|
|
775
|
+
'-f',
|
|
776
|
+
'--tail', String(initialLines),
|
|
777
|
+
];
|
|
778
|
+
if (service) {
|
|
779
|
+
args.push(service);
|
|
780
|
+
}
|
|
781
|
+
const cmd = composeCmd.split(' ')[0];
|
|
782
|
+
child = spawn(cmd, args, {
|
|
783
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
784
|
+
cwd,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
const containerName = getContainerName(building);
|
|
789
|
+
log.log(`Starting Docker log stream for: ${containerName} (gen ${currentGeneration})`);
|
|
790
|
+
child = spawn('docker', ['logs', '-f', '--tail', String(initialLines), containerName], {
|
|
791
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
activeLogStreams.set(buildingId, child);
|
|
795
|
+
// Handle stdout
|
|
796
|
+
child.stdout?.on('data', (data) => {
|
|
797
|
+
// Check if this stream is still the current generation
|
|
798
|
+
if (streamGenerations.get(buildingId) !== currentGeneration) {
|
|
799
|
+
return; // Discard chunks from old streams
|
|
800
|
+
}
|
|
801
|
+
const chunk = data.toString();
|
|
802
|
+
callbacks.onChunk(chunk, false, service);
|
|
803
|
+
});
|
|
804
|
+
// Handle stderr (Docker logs outputs to stderr)
|
|
805
|
+
child.stderr?.on('data', (data) => {
|
|
806
|
+
// Check if this stream is still the current generation
|
|
807
|
+
if (streamGenerations.get(buildingId) !== currentGeneration) {
|
|
808
|
+
return; // Discard chunks from old streams
|
|
809
|
+
}
|
|
810
|
+
const chunk = data.toString();
|
|
811
|
+
callbacks.onChunk(chunk, true, service);
|
|
812
|
+
});
|
|
813
|
+
// Handle process exit
|
|
814
|
+
child.on('close', (code) => {
|
|
815
|
+
log.log(`Docker log stream ended for ${buildingId} with code ${code}`);
|
|
816
|
+
activeLogStreams.delete(buildingId);
|
|
817
|
+
// Only call onEnd if this is still the current generation
|
|
818
|
+
if (streamGenerations.get(buildingId) === currentGeneration) {
|
|
819
|
+
callbacks.onEnd();
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
// Handle errors
|
|
823
|
+
child.on('error', (error) => {
|
|
824
|
+
log.error(`Docker log stream error for ${buildingId}: ${error.message}`);
|
|
825
|
+
activeLogStreams.delete(buildingId);
|
|
826
|
+
// Only call onError if this is still the current generation
|
|
827
|
+
if (streamGenerations.get(buildingId) === currentGeneration) {
|
|
828
|
+
callbacks.onError(error.message);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
const stop = () => {
|
|
832
|
+
stopLogStream(buildingId);
|
|
833
|
+
};
|
|
834
|
+
return { success: true, stop };
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
log.error(`Failed to start Docker log stream: ${error.message}`);
|
|
838
|
+
return { success: false, error: error.message, stop: () => { } };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Stop streaming logs for a building
|
|
843
|
+
*/
|
|
844
|
+
export function stopLogStream(buildingId) {
|
|
845
|
+
const child = activeLogStreams.get(buildingId);
|
|
846
|
+
if (child) {
|
|
847
|
+
log.log(`Stopping Docker log stream for building ${buildingId}`);
|
|
848
|
+
child.kill('SIGTERM');
|
|
849
|
+
activeLogStreams.delete(buildingId);
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Check if a log stream is active for a building
|
|
856
|
+
*/
|
|
857
|
+
export function isLogStreamActive(buildingId) {
|
|
858
|
+
return activeLogStreams.has(buildingId);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Get all active log stream building IDs
|
|
862
|
+
*/
|
|
863
|
+
export function getActiveLogStreams() {
|
|
864
|
+
return Array.from(activeLogStreams.keys());
|
|
865
|
+
}
|