s9n-devops-agent 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ An intelligent Git automation system with multi-agent support, session managemen
23
23
  - **⚙️ VS Code Integration**: Seamlessly integrates with VS Code tasks
24
24
  - **🔐 Safe Concurrent Development**: Multiple agents can work simultaneously without conflicts
25
25
  - **🏷️ Smart Branching**: Automatic branch creation with configurable naming patterns
26
+ - **🐋 Docker Auto-Restart**: Automatically restart Docker containers after code push (v1.2.0)
26
27
 
27
28
  ## Installation 📦
28
29
 
@@ -226,6 +227,30 @@ Add to `.vscode/tasks.json`:
226
227
 
227
228
  ## Advanced Features 🔬
228
229
 
230
+ ### Docker Container Auto-Restart (v1.2.0+)
231
+
232
+ Automatically restart Docker containers after pushing code changes:
233
+
234
+ ```bash
235
+ # During session creation, if docker-compose is detected:
236
+ s9n-devops-agent start
237
+ # -> Auto-restart Docker containers after push? (y/N): y
238
+ # -> Rebuild containers on restart? (y/N): n
239
+ # -> Specific service to restart (leave empty for all): app
240
+ ```
241
+
242
+ **Features:**
243
+ - Detects docker-compose files automatically
244
+ - Optional container rebuild on restart
245
+ - Target specific services or restart all
246
+ - Non-blocking: Docker failures don't affect git workflow
247
+ - Works with Docker Compose v1 and v2
248
+
249
+ **Supported docker-compose files:**
250
+ - docker-compose.yml / docker-compose.yaml
251
+ - compose.yml / compose.yaml
252
+ - docker-compose.dev.yml / docker-compose.local.yml
253
+
229
254
  ### Multi-Agent Collaboration
230
255
 
231
256
  Multiple AI assistants can work on different features simultaneously:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s9n-devops-agent",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "CS_DevOpsAgent - Intelligent Git Automation System with multi-agent support and session management",
5
5
  "type": "module",
6
6
  "main": "src/cs-devops-agent-worker.js",
@@ -113,6 +113,7 @@ import chokidar from "chokidar";
113
113
  import { execa } from "execa";
114
114
  import readline from "node:readline";
115
115
  import { stdin as input, stdout as output } from 'node:process';
116
+ import { restartDockerContainers } from './docker-utils.js';
116
117
 
117
118
  // ============================================================================
118
119
  // CONFIGURATION SECTION - All settings can be overridden via environment vars
@@ -762,7 +763,12 @@ async function commitOnce(repoRoot, msgPath) {
762
763
  if (PUSH) {
763
764
  const ok = await pushBranch(BRANCH);
764
765
  log(`push ${ok ? "ok" : "failed"}`);
765
- if (ok && CLEAR_MSG_WHEN === "push") clearMsgFile(msgPath);
766
+ if (ok) {
767
+ if (CLEAR_MSG_WHEN === "push") clearMsgFile(msgPath);
768
+
769
+ // Handle Docker restart if configured
770
+ await handleDockerRestart();
771
+ }
766
772
  }
767
773
  } finally {
768
774
  busy = false;
@@ -782,6 +788,52 @@ function schedule(repoRoot, msgPath) {
782
788
  }, QUIET_MS);
783
789
  }
784
790
 
791
+ // ============================================================================
792
+ // DOCKER CONTAINER MANAGEMENT
793
+ // ============================================================================
794
+
795
+ /**
796
+ * Handle Docker container restart if configured in session
797
+ */
798
+ async function handleDockerRestart() {
799
+ // Check if we're in a session with Docker configuration
800
+ const sessionConfigPath = path.join(process.cwd(), '.devops-session.json');
801
+ if (!fs.existsSync(sessionConfigPath)) {
802
+ return; // Not in a managed session
803
+ }
804
+
805
+ try {
806
+ const sessionConfig = JSON.parse(fs.readFileSync(sessionConfigPath, 'utf8'));
807
+
808
+ // Check if Docker restart is enabled
809
+ if (!sessionConfig.dockerConfig || !sessionConfig.dockerConfig.enabled) {
810
+ return;
811
+ }
812
+
813
+ log('🐋 Docker restart configured - initiating container restart...');
814
+
815
+ const dockerOptions = {
816
+ projectPath: process.cwd(),
817
+ composeFile: sessionConfig.dockerConfig.composeFile,
818
+ serviceName: sessionConfig.dockerConfig.service,
819
+ rebuild: sessionConfig.dockerConfig.rebuild,
820
+ forceRecreate: sessionConfig.dockerConfig.forceRecreate || false,
821
+ detach: true
822
+ };
823
+
824
+ const result = await restartDockerContainers(dockerOptions);
825
+
826
+ if (result.success) {
827
+ log('✅ Docker containers restarted successfully');
828
+ } else {
829
+ log(`⚠️ Docker restart failed: ${result.error}`);
830
+ }
831
+ } catch (error) {
832
+ // Don't fail the commit/push if Docker restart fails
833
+ dlog('Docker restart error:', error.message);
834
+ }
835
+ }
836
+
785
837
  // ============================================================================
786
838
  // INFRASTRUCTURE CHANGE DETECTION
787
839
  // ============================================================================
@@ -0,0 +1,290 @@
1
+ /**
2
+ * ============================================================================
3
+ * DOCKER UTILITIES FOR DEVOPS AGENT
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Provides Docker container management functionality for automatic restarts
8
+ * after code pushes. Integrates with the DevOps agent session workflow.
9
+ *
10
+ * ============================================================================
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { execa } from 'execa';
16
+
17
+ /**
18
+ * Check if Docker is installed and running
19
+ */
20
+ export async function isDockerAvailable() {
21
+ try {
22
+ await execa('docker', ['version'], { stdio: 'pipe' });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Check if docker-compose is available
31
+ */
32
+ export async function isDockerComposeAvailable() {
33
+ try {
34
+ // Try docker compose (v2)
35
+ await execa('docker', ['compose', 'version'], { stdio: 'pipe' });
36
+ return 'docker compose';
37
+ } catch {
38
+ try {
39
+ // Try docker-compose (v1)
40
+ await execa('docker-compose', ['version'], { stdio: 'pipe' });
41
+ return 'docker-compose';
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Find docker-compose files in the project
50
+ */
51
+ export function findDockerComposeFiles(projectPath) {
52
+ const possibleFiles = [
53
+ 'docker-compose.yml',
54
+ 'docker-compose.yaml',
55
+ 'compose.yml',
56
+ 'compose.yaml',
57
+ 'docker-compose.dev.yml',
58
+ 'docker-compose.dev.yaml',
59
+ 'docker-compose.local.yml',
60
+ 'docker-compose.local.yaml'
61
+ ];
62
+
63
+ const found = [];
64
+ for (const file of possibleFiles) {
65
+ const filePath = path.join(projectPath, file);
66
+ if (fs.existsSync(filePath)) {
67
+ found.push({
68
+ name: file,
69
+ path: filePath
70
+ });
71
+ }
72
+ }
73
+
74
+ return found;
75
+ }
76
+
77
+ /**
78
+ * Check if project has Docker configuration
79
+ */
80
+ export function hasDockerConfiguration(projectPath) {
81
+ const composeFiles = findDockerComposeFiles(projectPath);
82
+ const dockerfilePath = path.join(projectPath, 'Dockerfile');
83
+
84
+ return {
85
+ hasCompose: composeFiles.length > 0,
86
+ hasDockerfile: fs.existsSync(dockerfilePath),
87
+ composeFiles: composeFiles
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Restart Docker containers
93
+ */
94
+ export async function restartDockerContainers(options = {}) {
95
+ const {
96
+ projectPath = process.cwd(),
97
+ composeFile = null,
98
+ serviceName = null,
99
+ forceRecreate = false,
100
+ rebuild = false,
101
+ detach = true
102
+ } = options;
103
+
104
+ console.log('\n🐋 Docker Container Restart');
105
+ console.log('━'.repeat(50));
106
+
107
+ try {
108
+ // Check if Docker is available
109
+ const dockerCmd = await isDockerComposeAvailable();
110
+ if (!dockerCmd) {
111
+ throw new Error('Docker Compose is not available');
112
+ }
113
+
114
+ // Determine compose file to use
115
+ let composeFilePath = composeFile;
116
+ if (!composeFilePath) {
117
+ const dockerConfig = hasDockerConfiguration(projectPath);
118
+ if (dockerConfig.hasCompose && dockerConfig.composeFiles.length > 0) {
119
+ composeFilePath = dockerConfig.composeFiles[0].path;
120
+ } else {
121
+ throw new Error('No docker-compose file found');
122
+ }
123
+ }
124
+
125
+ // Build command arguments
126
+ const isV2 = dockerCmd === 'docker compose';
127
+ const baseCmd = isV2 ? ['docker', 'compose'] : ['docker-compose'];
128
+ const fileArgs = ['-f', composeFilePath];
129
+
130
+ // Stop containers
131
+ console.log('📦 Stopping containers...');
132
+ const stopArgs = [...fileArgs, 'stop'];
133
+ if (serviceName) stopArgs.push(serviceName);
134
+
135
+ const stopResult = await execa(baseCmd[0], [...baseCmd.slice(1), ...stopArgs], {
136
+ cwd: projectPath,
137
+ stdio: 'inherit'
138
+ });
139
+
140
+ // Optionally rebuild
141
+ if (rebuild) {
142
+ console.log('🔨 Rebuilding containers...');
143
+ const buildArgs = [...fileArgs, 'build'];
144
+ if (serviceName) buildArgs.push(serviceName);
145
+
146
+ await execa(baseCmd[0], [...baseCmd.slice(1), ...buildArgs], {
147
+ cwd: projectPath,
148
+ stdio: 'inherit'
149
+ });
150
+ }
151
+
152
+ // Start containers
153
+ console.log('🚀 Starting containers...');
154
+ const upArgs = [...fileArgs, 'up'];
155
+ if (forceRecreate) upArgs.push('--force-recreate');
156
+ if (detach) upArgs.push('-d');
157
+ if (serviceName) upArgs.push(serviceName);
158
+
159
+ await execa(baseCmd[0], [...baseCmd.slice(1), ...upArgs], {
160
+ cwd: projectPath,
161
+ stdio: 'inherit'
162
+ });
163
+
164
+ console.log('✅ Docker containers restarted successfully');
165
+ console.log('━'.repeat(50));
166
+ return { success: true };
167
+
168
+ } catch (error) {
169
+ console.error('❌ Failed to restart Docker containers:', error.message);
170
+ console.log('━'.repeat(50));
171
+ return { success: false, error: error.message };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Get Docker container status
177
+ */
178
+ export async function getDockerStatus(projectPath = process.cwd(), composeFile = null) {
179
+ try {
180
+ const dockerCmd = await isDockerComposeAvailable();
181
+ if (!dockerCmd) {
182
+ return { running: false, services: [] };
183
+ }
184
+
185
+ let composeFilePath = composeFile;
186
+ if (!composeFilePath) {
187
+ const dockerConfig = hasDockerConfiguration(projectPath);
188
+ if (dockerConfig.hasCompose && dockerConfig.composeFiles.length > 0) {
189
+ composeFilePath = dockerConfig.composeFiles[0].path;
190
+ } else {
191
+ return { running: false, services: [] };
192
+ }
193
+ }
194
+
195
+ const isV2 = dockerCmd === 'docker compose';
196
+ const baseCmd = isV2 ? ['docker', 'compose'] : ['docker-compose'];
197
+ const psArgs = ['-f', composeFilePath, 'ps', '--format', 'json'];
198
+
199
+ const result = await execa(baseCmd[0], [...baseCmd.slice(1), ...psArgs], {
200
+ cwd: projectPath,
201
+ stdio: 'pipe'
202
+ });
203
+
204
+ // Parse the JSON output
205
+ const services = [];
206
+ if (result.stdout) {
207
+ const lines = result.stdout.split('\n').filter(line => line.trim());
208
+ for (const line of lines) {
209
+ try {
210
+ const service = JSON.parse(line);
211
+ services.push({
212
+ name: service.Service || service.Name,
213
+ state: service.State,
214
+ status: service.Status,
215
+ ports: service.Ports
216
+ });
217
+ } catch {
218
+ // Skip unparseable lines
219
+ }
220
+ }
221
+ }
222
+
223
+ return {
224
+ running: services.some(s => s.state === 'running'),
225
+ services: services
226
+ };
227
+
228
+ } catch (error) {
229
+ return { running: false, services: [], error: error.message };
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Monitor Docker logs
235
+ */
236
+ export async function tailDockerLogs(options = {}) {
237
+ const {
238
+ projectPath = process.cwd(),
239
+ composeFile = null,
240
+ serviceName = null,
241
+ lines = 50,
242
+ follow = true
243
+ } = options;
244
+
245
+ try {
246
+ const dockerCmd = await isDockerComposeAvailable();
247
+ if (!dockerCmd) {
248
+ throw new Error('Docker Compose is not available');
249
+ }
250
+
251
+ let composeFilePath = composeFile;
252
+ if (!composeFilePath) {
253
+ const dockerConfig = hasDockerConfiguration(projectPath);
254
+ if (dockerConfig.hasCompose && dockerConfig.composeFiles.length > 0) {
255
+ composeFilePath = dockerConfig.composeFiles[0].path;
256
+ } else {
257
+ throw new Error('No docker-compose file found');
258
+ }
259
+ }
260
+
261
+ const isV2 = dockerCmd === 'docker compose';
262
+ const baseCmd = isV2 ? ['docker', 'compose'] : ['docker-compose'];
263
+ const logsArgs = ['-f', composeFilePath, 'logs'];
264
+
265
+ if (lines) logsArgs.push('--tail', lines.toString());
266
+ if (follow) logsArgs.push('-f');
267
+ if (serviceName) logsArgs.push(serviceName);
268
+
269
+ const proc = execa(baseCmd[0], [...baseCmd.slice(1), ...logsArgs], {
270
+ cwd: projectPath,
271
+ stdio: 'inherit'
272
+ });
273
+
274
+ return proc;
275
+
276
+ } catch (error) {
277
+ console.error('Failed to tail Docker logs:', error.message);
278
+ return null;
279
+ }
280
+ }
281
+
282
+ export default {
283
+ isDockerAvailable,
284
+ isDockerComposeAvailable,
285
+ findDockerComposeFiles,
286
+ hasDockerConfiguration,
287
+ restartDockerContainers,
288
+ getDockerStatus,
289
+ tailDockerLogs
290
+ };
@@ -19,9 +19,10 @@
19
19
 
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
- import { execSync, spawn, fork } from 'child_process';
23
- import { fileURLToPath } from 'url';
24
- import { dirname } from 'path';
22
+ import { execSync, spawn } from 'child_process';
23
+ import crypto from 'crypto';
24
+ import readline from 'readline';
25
+ import { hasDockerConfiguration } from './docker-utils.js';
25
26
  import crypto from 'crypto';
26
27
  import readline from 'readline';
27
28
 
@@ -367,7 +368,91 @@ class SessionCoordinator {
367
368
  }
368
369
 
369
370
  /**
370
- * Prompt for merge configuration
371
+ * Prompt for Docker restart configuration
372
+ */
373
+ async promptForDockerConfig() {
374
+ const rl = readline.createInterface({
375
+ input: process.stdin,
376
+ output: process.stdout
377
+ });
378
+
379
+ // Ask if they want automatic Docker restarts
380
+ const autoRestart = await new Promise((resolve) => {
381
+ rl.question('\nAuto-restart Docker containers after push? (y/N): ', (answer) => {
382
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
383
+ });
384
+ });
385
+
386
+ if (!autoRestart) {
387
+ rl.close();
388
+ return { enabled: false };
389
+ }
390
+
391
+ // Ask which compose file to use if multiple
392
+ const dockerInfo = hasDockerConfiguration(process.cwd());
393
+ let selectedComposeFile = null;
394
+
395
+ if (dockerInfo.composeFiles.length > 1) {
396
+ console.log(`\n${CONFIG.colors.bright}Select docker-compose file:${CONFIG.colors.reset}`);
397
+ dockerInfo.composeFiles.forEach((file, index) => {
398
+ console.log(` ${index + 1}) ${file.name}`);
399
+ });
400
+
401
+ const fileChoice = await new Promise((resolve) => {
402
+ rl.question(`Choose file (1-${dockerInfo.composeFiles.length}) [1]: `, (answer) => {
403
+ const choice = parseInt(answer) || 1;
404
+ if (choice >= 1 && choice <= dockerInfo.composeFiles.length) {
405
+ resolve(dockerInfo.composeFiles[choice - 1]);
406
+ } else {
407
+ resolve(dockerInfo.composeFiles[0]);
408
+ }
409
+ });
410
+ });
411
+
412
+ selectedComposeFile = fileChoice.path;
413
+ } else if (dockerInfo.composeFiles.length === 1) {
414
+ selectedComposeFile = dockerInfo.composeFiles[0].path;
415
+ }
416
+
417
+ // Ask about rebuild preference
418
+ const rebuild = await new Promise((resolve) => {
419
+ rl.question('\nRebuild containers on restart? (y/N): ', (answer) => {
420
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
421
+ });
422
+ });
423
+
424
+ // Ask about specific service
425
+ const specificService = await new Promise((resolve) => {
426
+ rl.question('\nSpecific service to restart (leave empty for all): ', (answer) => {
427
+ resolve(answer.trim() || null);
428
+ });
429
+ });
430
+
431
+ rl.close();
432
+
433
+ const config = {
434
+ enabled: true,
435
+ composeFile: selectedComposeFile,
436
+ rebuild: rebuild,
437
+ service: specificService,
438
+ forceRecreate: false
439
+ };
440
+
441
+ console.log(`\n${CONFIG.colors.green}✓${CONFIG.colors.reset} Docker restart configuration:`);
442
+ console.log(` ${CONFIG.colors.bright}Auto-restart:${CONFIG.colors.reset} Enabled`);
443
+ if (selectedComposeFile) {
444
+ console.log(` ${CONFIG.colors.bright}Compose file:${CONFIG.colors.reset} ${path.basename(selectedComposeFile)}`);
445
+ }
446
+ console.log(` ${CONFIG.colors.bright}Rebuild:${CONFIG.colors.reset} ${rebuild ? 'Yes' : 'No'}`);
447
+ if (specificService) {
448
+ console.log(` ${CONFIG.colors.bright}Service:${CONFIG.colors.reset} ${specificService}`);
449
+ }
450
+
451
+ return config;
452
+ }
453
+
454
+ /**
455
+ * Prompt for auto-merge configuration
371
456
  */
372
457
  async promptForMergeConfig() {
373
458
  const rl = readline.createInterface({
@@ -593,6 +678,27 @@ class SessionCoordinator {
593
678
  // Ask for auto-merge configuration
594
679
  const mergeConfig = await this.promptForMergeConfig();
595
680
 
681
+ // Check for Docker configuration and ask about restart preference
682
+ let dockerConfig = null;
683
+ const dockerInfo = hasDockerConfiguration(process.cwd());
684
+
685
+ if (dockerInfo.hasCompose || dockerInfo.hasDockerfile) {
686
+ console.log(`\n${CONFIG.colors.yellow}Docker Configuration Detected${CONFIG.colors.reset}`);
687
+
688
+ if (dockerInfo.hasCompose) {
689
+ console.log(`${CONFIG.colors.dim}Found docker-compose files:${CONFIG.colors.reset}`);
690
+ dockerInfo.composeFiles.forEach(file => {
691
+ console.log(` • ${file.name}`);
692
+ });
693
+ }
694
+
695
+ if (dockerInfo.hasDockerfile) {
696
+ console.log(`${CONFIG.colors.dim}Found Dockerfile${CONFIG.colors.reset}`);
697
+ }
698
+
699
+ dockerConfig = await this.promptForDockerConfig();
700
+ }
701
+
596
702
  // Create worktree with developer initials in the name
597
703
  const worktreeName = `${agentType}-${devInitials}-${sessionId}-${task.replace(/\s+/g, '-')}`;
598
704
  const worktreePath = path.join(this.worktreesPath, worktreeName);
@@ -615,7 +721,8 @@ class SessionCoordinator {
615
721
  status: 'active',
616
722
  pid: process.pid,
617
723
  developerInitials: devInitials,
618
- mergeConfig: mergeConfig
724
+ mergeConfig: mergeConfig,
725
+ dockerConfig: dockerConfig
619
726
  };
620
727
 
621
728
  const lockFile = path.join(this.locksPath, `${sessionId}.lock`);