diff --git a/changelog.md b/changelog.md index 3b573c0..4297d21 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-08-16 - 1.18.0 - feat(services) +Add Docker port mapping sync and reconfigure workflow for local services + +- Add getPortMappings to DockerContainer to extract port bindings from docker inspect output +- Sync existing container port mappings into .nogit/env.json when loading/creating service configuration +- Validate and automatically update ports only when containers are not present; preserve container ports when containers exist +- Recreate containers automatically if detected container port mappings differ from configuration (MongoDB and MinIO) +- Add reconfigure method and new CLI command to reassign ports and optionally restart services +- Improve status output to show configured ports and port availability information +- Minor helpers and imports updated (DockerContainer injected into ServiceConfiguration) +- Add .claude/settings.local.json (local permissions config) to repository + ## 2025-08-15 - 1.17.5 - fix(services) Update S3 credentials naming and add S3_ENDPOINT/S3_USESSL support for improved MinIO integration diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 80a8b53..2f617e6 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/cli', - version: '1.17.5', + version: '1.18.0', description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.' } diff --git a/ts/mod_services/classes.dockercontainer.ts b/ts/mod_services/classes.dockercontainer.ts index a9d7e5f..7daadd2 100644 --- a/ts/mod_services/classes.dockercontainer.ts +++ b/ts/mod_services/classes.dockercontainer.ts @@ -215,7 +215,7 @@ export class DockerContainer { */ public async inspect(containerName: string): Promise { try { - const result = await this.smartshell.exec(`docker inspect ${containerName}`); + const result = await this.smartshell.exec(`docker inspect ${containerName} 2>/dev/null`); if (result.exitCode === 0) { return JSON.parse(result.stdout); } @@ -224,4 +224,38 @@ export class DockerContainer { return null; } } + + /** + * Get port mappings for a container + */ + public async getPortMappings(containerName: string): Promise<{ [key: string]: string } | null> { + try { + // Use docker inspect without format to get full JSON, then extract PortBindings + const result = await this.smartshell.exec(`docker inspect ${containerName} 2>/dev/null`); + + if (result.exitCode === 0 && result.stdout) { + const inspectData = JSON.parse(result.stdout); + if (inspectData && inspectData[0] && inspectData[0].HostConfig && inspectData[0].HostConfig.PortBindings) { + const portBindings = inspectData[0].HostConfig.PortBindings; + const mappings: { [key: string]: string } = {}; + + // Convert Docker's port binding format to simple host:container mapping + for (const [containerPort, hostBindings] of Object.entries(portBindings)) { + if (Array.isArray(hostBindings) && hostBindings.length > 0) { + const hostPort = (hostBindings[0] as any).HostPort; + if (hostPort) { + mappings[containerPort.replace('/tcp', '').replace('/udp', '')] = hostPort; + } + } + } + + return mappings; + } + } + return null; + } catch (error) { + // Silently fail - container might not exist + return null; + } + } } \ No newline at end of file diff --git a/ts/mod_services/classes.serviceconfiguration.ts b/ts/mod_services/classes.serviceconfiguration.ts index 3f4eaec..ac123fd 100644 --- a/ts/mod_services/classes.serviceconfiguration.ts +++ b/ts/mod_services/classes.serviceconfiguration.ts @@ -1,6 +1,7 @@ import * as plugins from './mod.plugins.js'; import * as helpers from './helpers.js'; import { logger } from '../gitzone.logging.js'; +import { DockerContainer } from './classes.dockercontainer.js'; export interface IServiceConfig { PROJECT_NAME: string; @@ -23,9 +24,11 @@ export interface IServiceConfig { export class ServiceConfiguration { private configPath: string; private config: IServiceConfig; + private docker: DockerContainer; constructor() { this.configPath = plugins.path.join(process.cwd(), '.nogit', 'env.json'); + this.docker = new DockerContainer(); } /** @@ -41,6 +44,9 @@ export class ServiceConfiguration { await this.createDefaultConfig(); } + // Sync ports from existing Docker containers if they exist + await this.syncPortsFromDocker(); + return this.config; } @@ -280,4 +286,147 @@ export class ServiceConfiguration { minio: plugins.path.join(process.cwd(), '.nogit', 'miniodata') }; } + + /** + * Sync port configuration from existing Docker containers + */ + private async syncPortsFromDocker(): Promise { + const containers = this.getContainerNames(); + let updated = false; + + // Check MongoDB container + const mongoStatus = await this.docker.getStatus(containers.mongo); + if (mongoStatus !== 'not_exists') { + const portMappings = await this.docker.getPortMappings(containers.mongo); + if (portMappings && portMappings['27017']) { + const dockerPort = portMappings['27017']; + if (this.config.MONGODB_PORT !== dockerPort) { + this.config.MONGODB_PORT = dockerPort; + updated = true; + } + } + } + + // Check MinIO container + const minioStatus = await this.docker.getStatus(containers.minio); + if (minioStatus !== 'not_exists') { + const portMappings = await this.docker.getPortMappings(containers.minio); + if (portMappings) { + if (portMappings['9000']) { + const dockerPort = portMappings['9000']; + if (this.config.S3_PORT !== dockerPort) { + this.config.S3_PORT = dockerPort; + updated = true; + } + } + if (portMappings['9001']) { + const dockerPort = portMappings['9001']; + if (this.config.S3_CONSOLE_PORT !== dockerPort) { + this.config.S3_CONSOLE_PORT = dockerPort; + updated = true; + } + } + } + } + + if (updated) { + // Update derived fields + this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`; + const protocol = this.config.S3_USESSL ? 'https' : 'http'; + this.config.S3_ENDPOINT = `${protocol}://${this.config.S3_HOST}:${this.config.S3_PORT}`; + + await this.saveConfig(); + } + } + + /** + * Validate and update ports if they're not available + */ + public async validateAndUpdatePorts(): Promise { + let updated = false; + const containers = this.getContainerNames(); + + // Check if containers exist - if they do, ports are fine + const mongoExists = await this.docker.exists(containers.mongo); + const minioExists = await this.docker.exists(containers.minio); + + // Only check port availability if containers don't exist + if (!mongoExists) { + const mongoPort = parseInt(this.config.MONGODB_PORT); + if (!(await helpers.isPortAvailable(mongoPort))) { + logger.log('note', `⚠️ MongoDB port ${mongoPort} is in use, finding new port...`); + const newPort = await helpers.getRandomAvailablePort(); + this.config.MONGODB_PORT = newPort.toString(); + logger.log('ok', `✅ New MongoDB port: ${newPort}`); + updated = true; + } + } + + if (!minioExists) { + const s3Port = parseInt(this.config.S3_PORT); + const s3ConsolePort = parseInt(this.config.S3_CONSOLE_PORT); + + if (!(await helpers.isPortAvailable(s3Port))) { + logger.log('note', `⚠️ S3 API port ${s3Port} is in use, finding new port...`); + const newPort = await helpers.getRandomAvailablePort(); + this.config.S3_PORT = newPort.toString(); + logger.log('ok', `✅ New S3 API port: ${newPort}`); + updated = true; + } + + if (!(await helpers.isPortAvailable(s3ConsolePort))) { + logger.log('note', `⚠️ S3 Console port ${s3ConsolePort} is in use, finding new port...`); + let newPort = parseInt(this.config.S3_PORT) + 1; + while (!(await helpers.isPortAvailable(newPort))) { + newPort++; + } + this.config.S3_CONSOLE_PORT = newPort.toString(); + logger.log('ok', `✅ New S3 Console port: ${newPort}`); + updated = true; + } + } + + if (updated) { + // Update derived fields + this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`; + const protocol = this.config.S3_USESSL ? 'https' : 'http'; + this.config.S3_ENDPOINT = `${protocol}://${this.config.S3_HOST}:${this.config.S3_PORT}`; + + await this.saveConfig(); + } + + return updated; + } + + /** + * Force reconfigure all ports with new available ones + */ + public async reconfigurePorts(): Promise { + logger.log('note', '🔄 Finding new available ports...'); + + const mongoPort = await helpers.getRandomAvailablePort(); + const s3Port = await helpers.getRandomAvailablePort(); + let s3ConsolePort = s3Port + 1; + + // Ensure console port is also available + while (!(await helpers.isPortAvailable(s3ConsolePort))) { + s3ConsolePort++; + } + + this.config.MONGODB_PORT = mongoPort.toString(); + this.config.S3_PORT = s3Port.toString(); + this.config.S3_CONSOLE_PORT = s3ConsolePort.toString(); + + // Update derived fields + this.config.MONGODB_URL = `mongodb://${this.config.MONGODB_USER}:${this.config.MONGODB_PASS}@${this.config.MONGODB_HOST}:${this.config.MONGODB_PORT}/${this.config.MONGODB_NAME}?authSource=admin`; + const protocol = this.config.S3_USESSL ? 'https' : 'http'; + this.config.S3_ENDPOINT = `${protocol}://${this.config.S3_HOST}:${this.config.S3_PORT}`; + + await this.saveConfig(); + + logger.log('ok', '✅ New port configuration:'); + logger.log('info', ` 📍 MongoDB: ${mongoPort}`); + logger.log('info', ` 📍 S3 API: ${s3Port}`); + logger.log('info', ` 📍 S3 Console: ${s3ConsolePort}`); + } } \ No newline at end of file diff --git a/ts/mod_services/classes.servicemanager.ts b/ts/mod_services/classes.servicemanager.ts index 8843fb0..2f22afa 100644 --- a/ts/mod_services/classes.servicemanager.ts +++ b/ts/mod_services/classes.servicemanager.ts @@ -26,6 +26,9 @@ export class ServiceManager { // Load or create configuration await this.config.loadOrCreate(); logger.log('info', `📋 Project: ${this.config.getConfig().PROJECT_NAME}`); + + // Validate and update ports if needed + await this.config.validateAndUpdatePorts(); } /** @@ -49,10 +52,42 @@ export class ServiceManager { break; case 'stopped': - if (await this.docker.start(containers.mongo)) { - logger.log('ok', ' Started ✓'); + // Check if port mapping matches config + const mongoPortMappings = await this.docker.getPortMappings(containers.mongo); + if (mongoPortMappings && mongoPortMappings['27017'] !== config.MONGODB_PORT) { + logger.log('note', ' Port configuration changed, recreating container...'); + await this.docker.remove(containers.mongo, true); + // Fall through to create new container + const success = await this.docker.run({ + name: containers.mongo, + image: 'mongo:7.0', + ports: { + [`0.0.0.0:${config.MONGODB_PORT}`]: '27017' + }, + volumes: { + [directories.mongo]: '/data/db' + }, + environment: { + MONGO_INITDB_ROOT_USERNAME: config.MONGODB_USER, + MONGO_INITDB_ROOT_PASSWORD: config.MONGODB_PASS, + MONGO_INITDB_DATABASE: config.MONGODB_NAME + }, + restart: 'unless-stopped', + command: '--bind_ip_all' + }); + + if (success) { + logger.log('ok', ' Recreated with new port ✓'); + } else { + logger.log('error', ' Failed to recreate container'); + } } else { - logger.log('error', ' Failed to start'); + // Ports match, just start the container + if (await this.docker.start(containers.mongo)) { + logger.log('ok', ' Started ✓'); + } else { + logger.log('error', ' Failed to start'); + } } break; @@ -116,10 +151,60 @@ export class ServiceManager { break; case 'stopped': - if (await this.docker.start(containers.minio)) { - logger.log('ok', ' Started ✓'); + // Check if port mapping matches config + const minioPortMappings = await this.docker.getPortMappings(containers.minio); + if (minioPortMappings && + (minioPortMappings['9000'] !== config.S3_PORT || + minioPortMappings['9001'] !== config.S3_CONSOLE_PORT)) { + logger.log('note', ' Port configuration changed, recreating container...'); + await this.docker.remove(containers.minio, true); + // Fall through to create new container + const success = await this.docker.run({ + name: containers.minio, + image: 'minio/minio', + ports: { + [config.S3_PORT]: '9000', + [config.S3_CONSOLE_PORT]: '9001' + }, + volumes: { + [directories.minio]: '/data' + }, + environment: { + MINIO_ROOT_USER: config.S3_ACCESSKEY, + MINIO_ROOT_PASSWORD: config.S3_SECRETKEY + }, + restart: 'unless-stopped', + command: 'server /data --console-address ":9001"' + }); + + if (success) { + logger.log('ok', ' Recreated with new ports ✓'); + + // Wait for MinIO to be ready + await plugins.smartdelay.delayFor(3000); + + // Create default bucket + await this.docker.exec( + containers.minio, + `mc alias set local http://localhost:9000 ${config.S3_ACCESSKEY} ${config.S3_SECRETKEY}` + ); + + await this.docker.exec( + containers.minio, + `mc mb local/${config.S3_BUCKET}` + ); + + logger.log('ok', ` Bucket '${config.S3_BUCKET}' created ✓`); + } else { + logger.log('error', ' Failed to recreate container'); + } } else { - logger.log('error', ' Failed to start'); + // Ports match, just start the container + if (await this.docker.start(containers.minio)) { + logger.log('ok', ' Started ✓'); + } else { + logger.log('error', ' Failed to start'); + } } break; @@ -233,6 +318,7 @@ export class ServiceManager { case 'running': logger.log('ok', '📦 MongoDB: 🟢 Running'); logger.log('info', ` ├─ Container: ${containers.mongo}`); + logger.log('info', ` ├─ Port: ${config.MONGODB_PORT}`); logger.log('info', ` ├─ Connection: ${this.config.getMongoConnectionString()}`); // Show Compass connection string @@ -242,10 +328,19 @@ export class ServiceManager { break; case 'stopped': logger.log('note', '📦 MongoDB: 🟡 Stopped'); - logger.log('info', ` └─ Container: ${containers.mongo}`); + logger.log('info', ` ├─ Container: ${containers.mongo}`); + logger.log('info', ` └─ Port: ${config.MONGODB_PORT}`); break; case 'not_exists': logger.log('info', '📦 MongoDB: ⚪ Not installed'); + // Check port availability + const mongoPort = parseInt(config.MONGODB_PORT); + const mongoAvailable = await helpers.isPortAvailable(mongoPort); + if (!mongoAvailable) { + logger.log('error', ` └─ ⚠️ Port ${mongoPort} is in use by another process`); + } else { + logger.log('info', ` └─ Port ${mongoPort} is available`); + } break; } @@ -261,10 +356,33 @@ export class ServiceManager { break; case 'stopped': logger.log('note', '📦 S3/MinIO: 🟡 Stopped'); - logger.log('info', ` └─ Container: ${containers.minio}`); + logger.log('info', ` ├─ Container: ${containers.minio}`); + logger.log('info', ` ├─ API Port: ${config.S3_PORT}`); + logger.log('info', ` └─ Console Port: ${config.S3_CONSOLE_PORT}`); break; case 'not_exists': logger.log('info', '📦 S3/MinIO: ⚪ Not installed'); + // Check port availability + const s3Port = parseInt(config.S3_PORT); + const s3ConsolePort = parseInt(config.S3_CONSOLE_PORT); + const s3Available = await helpers.isPortAvailable(s3Port); + const consoleAvailable = await helpers.isPortAvailable(s3ConsolePort); + + if (!s3Available || !consoleAvailable) { + if (!s3Available) { + logger.log('error', ` ├─ ⚠️ API Port ${s3Port} is in use`); + } else { + logger.log('info', ` ├─ API Port ${s3Port} is available`); + } + if (!consoleAvailable) { + logger.log('error', ` └─ ⚠️ Console Port ${s3ConsolePort} is in use`); + } else { + logger.log('info', ` └─ Console Port ${s3ConsolePort} is available`); + } + } else { + logger.log('info', ` ├─ API Port ${s3Port} is available`); + logger.log('info', ` └─ Console Port ${s3ConsolePort} is available`); + } break; } } @@ -422,4 +540,44 @@ export class ServiceManager { logger.log('note', ' No data to clean'); } } + + /** + * Reconfigure services with new ports + */ + public async reconfigure(): Promise { + helpers.printHeader('Reconfiguring Services'); + + const containers = this.config.getContainerNames(); + + // Stop existing containers + logger.log('note', '🛑 Stopping existing containers...'); + + if (await this.docker.exists(containers.mongo)) { + await this.docker.stop(containers.mongo); + logger.log('ok', ' MongoDB stopped ✓'); + } + + if (await this.docker.exists(containers.minio)) { + await this.docker.stop(containers.minio); + logger.log('ok', ' S3/MinIO stopped ✓'); + } + + // Reconfigure ports + await this.config.reconfigurePorts(); + + // Ask if user wants to restart services + const smartinteract = new plugins.smartinteract.SmartInteract(); + const response = await smartinteract.askQuestion({ + name: 'restart', + type: 'confirm', + message: 'Do you want to start services with new ports?', + default: true + }); + + if (response.value) { + console.log(); + await this.startMongoDB(); + await this.startMinIO(); + } + } } diff --git a/ts/mod_services/index.ts b/ts/mod_services/index.ts index 209846b..dd438d3 100644 --- a/ts/mod_services/index.ts +++ b/ts/mod_services/index.ts @@ -48,6 +48,10 @@ export const run = async (argvArg: any) => { await handleClean(serviceManager); break; + case 'reconfigure': + await serviceManager.reconfigure(); + break; + case 'help': default: showHelp(); @@ -195,6 +199,7 @@ function showHelp() { logger.log('info', ' config Show current configuration'); logger.log('info', ' compass Show MongoDB Compass connection string'); logger.log('info', ' logs [service] Show logs (mongo|s3|all) [lines]'); + logger.log('info', ' reconfigure Reassign ports and restart services'); logger.log('info', ' remove Remove all containers'); logger.log('info', ' clean Remove all containers and data ⚠️'); logger.log('info', ' help Show this help message');