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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +364 -0
  3. package/dist/assets/characters/Textures/colormap.png +0 -0
  4. package/dist/assets/characters/character-female-a.glb +0 -0
  5. package/dist/assets/characters/character-female-b.glb +0 -0
  6. package/dist/assets/characters/character-female-c.glb +0 -0
  7. package/dist/assets/characters/character-female-d.glb +0 -0
  8. package/dist/assets/characters/character-female-e.glb +0 -0
  9. package/dist/assets/characters/character-female-f.glb +0 -0
  10. package/dist/assets/characters/character-male-a-processed.gltf +11862 -0
  11. package/dist/assets/characters/character-male-a.glb +0 -0
  12. package/dist/assets/characters/character-male-b.glb +0 -0
  13. package/dist/assets/characters/character-male-c.glb +0 -0
  14. package/dist/assets/characters/character-male-d.glb +0 -0
  15. package/dist/assets/characters/character-male-e.glb +0 -0
  16. package/dist/assets/characters/character-male-f.glb +0 -0
  17. package/dist/assets/icons/icon-192.png +0 -0
  18. package/dist/assets/icons/icon-512.png +0 -0
  19. package/dist/assets/landing-Cc0MDBAK.css +1 -0
  20. package/dist/assets/main-BIpLsrUu.css +1 -0
  21. package/dist/assets/main-DMTRw3br.js +276 -0
  22. package/dist/assets/textures/concrete_floor_worn_001_diff_1k.jpg +0 -0
  23. package/dist/assets/textures/logo-blanco.png +0 -0
  24. package/dist/assets/vendor-react-uS-d4TUT.js +17 -0
  25. package/dist/assets/vendor-three-4iQNXcoo.js +3828 -0
  26. package/dist/assets/web-BZdi2lG9.js +1 -0
  27. package/dist/assets/web-yHsOO1Qb.js +1 -0
  28. package/dist/index.html +38 -0
  29. package/dist/manifest.json +39 -0
  30. package/dist/src/packages/landing/index.html +463 -0
  31. package/dist/src/packages/server/app.js +87 -0
  32. package/dist/src/packages/server/auth/index.js +121 -0
  33. package/dist/src/packages/server/claude/backend.js +578 -0
  34. package/dist/src/packages/server/claude/index.js +8 -0
  35. package/dist/src/packages/server/claude/runner/internal-events.js +22 -0
  36. package/dist/src/packages/server/claude/runner/process-lifecycle.js +208 -0
  37. package/dist/src/packages/server/claude/runner/recovery-store.js +72 -0
  38. package/dist/src/packages/server/claude/runner/resource-monitor.js +51 -0
  39. package/dist/src/packages/server/claude/runner/restart-policy.js +69 -0
  40. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +153 -0
  41. package/dist/src/packages/server/claude/runner/watchdog.js +114 -0
  42. package/dist/src/packages/server/claude/runner.js +310 -0
  43. package/dist/src/packages/server/claude/session-loader.js +898 -0
  44. package/dist/src/packages/server/claude/types.js +5 -0
  45. package/dist/src/packages/server/cli.js +113 -0
  46. package/dist/src/packages/server/codex/backend.js +119 -0
  47. package/dist/src/packages/server/codex/index.js +2 -0
  48. package/dist/src/packages/server/codex/json-event-parser.js +612 -0
  49. package/dist/src/packages/server/data/builtin-skills/bitbucket-pr.js +298 -0
  50. package/dist/src/packages/server/data/builtin-skills/full-notifications.js +49 -0
  51. package/dist/src/packages/server/data/builtin-skills/git-captain.js +304 -0
  52. package/dist/src/packages/server/data/builtin-skills/index.js +61 -0
  53. package/dist/src/packages/server/data/builtin-skills/pm2-logs.js +354 -0
  54. package/dist/src/packages/server/data/builtin-skills/send-message-to-agent.js +51 -0
  55. package/dist/src/packages/server/data/builtin-skills/server-logs.js +124 -0
  56. package/dist/src/packages/server/data/builtin-skills/streaming-exec.js +94 -0
  57. package/dist/src/packages/server/data/builtin-skills/types.js +4 -0
  58. package/dist/src/packages/server/data/builtin-skills.js +6 -0
  59. package/dist/src/packages/server/data/index.js +890 -0
  60. package/dist/src/packages/server/data/snapshots.js +371 -0
  61. package/dist/src/packages/server/index.js +96 -0
  62. package/dist/src/packages/server/prompts/tide-commander.js +13 -0
  63. package/dist/src/packages/server/routes/agents.js +406 -0
  64. package/dist/src/packages/server/routes/config.js +347 -0
  65. package/dist/src/packages/server/routes/custom-models.js +170 -0
  66. package/dist/src/packages/server/routes/exec.js +269 -0
  67. package/dist/src/packages/server/routes/files.js +995 -0
  68. package/dist/src/packages/server/routes/index.js +38 -0
  69. package/dist/src/packages/server/routes/notifications.js +81 -0
  70. package/dist/src/packages/server/routes/permissions.js +115 -0
  71. package/dist/src/packages/server/routes/snapshots.js +224 -0
  72. package/dist/src/packages/server/routes/stt.js +99 -0
  73. package/dist/src/packages/server/routes/tts.js +166 -0
  74. package/dist/src/packages/server/routes/voice-assistant.js +310 -0
  75. package/dist/src/packages/server/runtime/claude-runtime-provider.js +10 -0
  76. package/dist/src/packages/server/runtime/codex-runtime-provider.js +11 -0
  77. package/dist/src/packages/server/runtime/index.js +2 -0
  78. package/dist/src/packages/server/runtime/types.js +6 -0
  79. package/dist/src/packages/server/services/agent-lifecycle-service.js +82 -0
  80. package/dist/src/packages/server/services/agent-service.js +410 -0
  81. package/dist/src/packages/server/services/boss-message-service.js +430 -0
  82. package/dist/src/packages/server/services/boss-service.js +553 -0
  83. package/dist/src/packages/server/services/building-service.js +867 -0
  84. package/dist/src/packages/server/services/claude-service.js +5 -0
  85. package/dist/src/packages/server/services/custom-class-service.js +323 -0
  86. package/dist/src/packages/server/services/database-service.js +914 -0
  87. package/dist/src/packages/server/services/docker-service.js +865 -0
  88. package/dist/src/packages/server/services/fileTracker.js +242 -0
  89. package/dist/src/packages/server/services/index.js +21 -0
  90. package/dist/src/packages/server/services/permission-service.js +258 -0
  91. package/dist/src/packages/server/services/pm2-service.js +435 -0
  92. package/dist/src/packages/server/services/runtime-command-execution.js +168 -0
  93. package/dist/src/packages/server/services/runtime-events.js +357 -0
  94. package/dist/src/packages/server/services/runtime-service.js +308 -0
  95. package/dist/src/packages/server/services/runtime-status-sync.js +104 -0
  96. package/dist/src/packages/server/services/runtime-subagents.js +50 -0
  97. package/dist/src/packages/server/services/runtime-watchdog.js +74 -0
  98. package/dist/src/packages/server/services/secrets-service.js +206 -0
  99. package/dist/src/packages/server/services/skill-service.js +508 -0
  100. package/dist/src/packages/server/services/subordinate-context-service.js +223 -0
  101. package/dist/src/packages/server/services/supervisor-claude.js +132 -0
  102. package/dist/src/packages/server/services/supervisor-prompts.js +80 -0
  103. package/dist/src/packages/server/services/supervisor-service.js +659 -0
  104. package/dist/src/packages/server/services/work-plan-service.js +476 -0
  105. package/dist/src/packages/server/setup.js +86 -0
  106. package/dist/src/packages/server/utils/index.js +4 -0
  107. package/dist/src/packages/server/utils/logger.js +302 -0
  108. package/dist/src/packages/server/utils/string.js +39 -0
  109. package/dist/src/packages/server/utils/tool-formatting.js +139 -0
  110. package/dist/src/packages/server/utils/unicode.js +46 -0
  111. package/dist/src/packages/server/websocket/handler.js +290 -0
  112. package/dist/src/packages/server/websocket/handlers/agent-handler.js +515 -0
  113. package/dist/src/packages/server/websocket/handlers/boss-handler.js +116 -0
  114. package/dist/src/packages/server/websocket/handlers/boss-response-handler.js +250 -0
  115. package/dist/src/packages/server/websocket/handlers/building-handler.js +298 -0
  116. package/dist/src/packages/server/websocket/handlers/command-handler.js +217 -0
  117. package/dist/src/packages/server/websocket/handlers/custom-class-handler.js +68 -0
  118. package/dist/src/packages/server/websocket/handlers/database-handler.js +223 -0
  119. package/dist/src/packages/server/websocket/handlers/notification-handler.js +25 -0
  120. package/dist/src/packages/server/websocket/handlers/permission-handler.js +21 -0
  121. package/dist/src/packages/server/websocket/handlers/secrets-handler.js +61 -0
  122. package/dist/src/packages/server/websocket/handlers/skill-handler.js +148 -0
  123. package/dist/src/packages/server/websocket/handlers/supervisor-handler.js +44 -0
  124. package/dist/src/packages/server/websocket/handlers/sync-handler.js +19 -0
  125. package/dist/src/packages/server/websocket/handlers/types.js +4 -0
  126. package/dist/src/packages/server/websocket/listeners/boss-listeners.js +21 -0
  127. package/dist/src/packages/server/websocket/listeners/index.js +32 -0
  128. package/dist/src/packages/server/websocket/listeners/permission-listeners.js +19 -0
  129. package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +196 -0
  130. package/dist/src/packages/server/websocket/listeners/skill-listeners.js +51 -0
  131. package/dist/src/packages/server/websocket/listeners/supervisor-listeners.js +37 -0
  132. package/dist/src/packages/shared/agent-types.js +54 -0
  133. package/dist/src/packages/shared/building-types.js +43 -0
  134. package/dist/src/packages/shared/common-types.js +1 -0
  135. package/dist/src/packages/shared/database-types.js +8 -0
  136. package/dist/src/packages/shared/types/snapshot.js +7 -0
  137. package/dist/src/packages/shared/types.js +12 -0
  138. package/dist/src/packages/shared/websocket-messages.js +1 -0
  139. package/dist/sw.js +37 -0
  140. 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
+ }