Files
idea.llm.gitea.repo.docker.…/cli/commands/init.js
2026-01-26 22:33:55 -06:00

707 lines
19 KiB
JavaScript

import chalk from 'chalk';
import inquirer from 'inquirer';
import { resolve, join, basename } from 'path';
import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { detectProject } from '../detectors/index.js';
import { createProjectConfig, saveProjectConfig, loadGlobalConfig } from '../utils/config-manager.js';
import { processTemplate, getTemplateFiles, buildTemplateContext, loadTemplate, renderTemplate, TEMPLATES_DIR } from '../utils/template-engine.js';
/**
* Init command - initialize Docker configuration for a project
*/
export async function initCommand(path, options) {
const projectPath = resolve(path);
if (!existsSync(projectPath)) {
throw new Error(`Path does not exist: ${projectPath}`);
}
const projectName = basename(projectPath);
console.log(chalk.blue('Initializing Docker configuration for:'), projectName);
console.log();
// Detect project type
let detection;
if (options.type) {
// Force specific type
detection = {
type: options.type,
dockerizable: true,
template: options.type.replace('-', '/'),
port: options.port || 3000,
projectName,
projectPath
};
console.log(chalk.yellow('Using forced type:'), options.type);
} else {
detection = await detectProject(projectPath);
}
if (!detection.dockerizable) {
console.log(chalk.red('✗ Project is not dockerizable:'), detection.reason);
return;
}
console.log(chalk.green('✓ Detected:'), detection.description || detection.type);
console.log();
// Check for existing files
const existingFiles = checkExistingFiles(projectPath);
if (existingFiles.length > 0 && !options.overwrite) {
console.log(chalk.yellow('Existing Docker files found:'));
existingFiles.forEach(f => console.log(chalk.gray(` - ${f}`)));
console.log();
if (options.interactive !== false) {
const { proceed } = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: 'Overwrite existing files?',
default: false
}]);
if (!proceed) {
console.log(chalk.yellow('Cancelled.'));
return;
}
} else {
console.log(chalk.yellow('Use --overwrite to replace existing files.'));
return;
}
}
// Interactive configuration
let config;
if (options.interactive !== false) {
config = await interactiveConfig(projectName, detection, options);
} else {
config = createProjectConfig(projectName, detection, {
port: options.port,
name: options.name
});
}
// Dry run - just show what would be generated
if (options.dryRun) {
console.log(chalk.blue('Dry run - would generate:'));
console.log();
const files = getFilesToGenerate(detection, config);
files.forEach(f => console.log(chalk.gray(` - ${f.output}`)));
console.log();
console.log(chalk.gray('Configuration:'));
console.log(JSON.stringify(config, null, 2));
return;
}
// Generate files
console.log();
console.log(chalk.blue('Generating files...'));
const generatedFiles = await generateFiles(projectPath, detection, config);
console.log();
generatedFiles.forEach(file => {
console.log(chalk.green('✓'), file);
});
console.log();
console.log(chalk.green('Docker configuration initialized successfully!'));
console.log();
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(' 1. Review generated files'));
console.log(chalk.gray(' 2. Create .env file from .env.example (if needed)'));
console.log(chalk.gray(' 3. Build and test locally:'), 'docker compose up --build');
console.log(chalk.gray(' 4. Deploy using:'), '.\\deploy-docker-auto.ps1', chalk.gray('or'), '.\\build-image-tar.ps1');
}
/**
* Interactive configuration prompts
*/
async function interactiveConfig(projectName, detection, options) {
const globalConfig = loadGlobalConfig();
const sanitizedName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const answers = await inquirer.prompt([
{
type: 'input',
name: 'containerName',
message: 'Container name:',
default: options.name || sanitizedName
},
{
type: 'number',
name: 'port',
message: 'Application port:',
default: options.port || detection.port || 3000
},
{
type: 'confirm',
name: 'useEnvFile',
message: 'Use .env file for configuration?',
default: true
},
{
type: 'confirm',
name: 'configureSSH',
message: 'Configure SSH deployment?',
default: true
}
]);
let sshConfig = {};
if (answers.configureSSH) {
sshConfig = await inquirer.prompt([
{
type: 'input',
name: 'sshHost',
message: 'SSH host:',
default: globalConfig.deployment?.sshHost || '192.168.8.178'
},
{
type: 'input',
name: 'sshUser',
message: 'SSH user:',
default: globalConfig.deployment?.sshUser || 'deployer'
}
]);
}
// Ask about volumes for projects that might need persistence
let volumes = [];
if (detection.type.includes('express') || detection.type.includes('python')) {
const { needsVolumes } = await inquirer.prompt([{
type: 'confirm',
name: 'needsVolumes',
message: 'Does this project need persistent data volumes?',
default: false
}]);
if (needsVolumes) {
const { volumePath } = await inquirer.prompt([{
type: 'input',
name: 'volumePath',
message: 'Volume mount (local:container):',
default: './data:/app/data'
}]);
volumes = [volumePath];
}
}
return createProjectConfig(projectName, detection, {
name: answers.containerName,
port: answers.port,
envFile: answers.useEnvFile,
sshHost: sshConfig.sshHost,
sshUser: sshConfig.sshUser,
volumes
});
}
/**
* Check for existing Docker files
*/
function checkExistingFiles(projectPath) {
const filesToCheck = [
'Dockerfile',
'docker-compose.yml',
'docker-compose.yaml',
'.dockerignore',
'docker-deployment.json'
];
return filesToCheck.filter(f => existsSync(join(projectPath, f)));
}
/**
* Get list of files to generate
*/
function getFilesToGenerate(detection, config) {
const files = [
{ output: 'Dockerfile' },
{ output: 'docker-compose.yml' },
{ output: '.dockerignore' },
{ output: 'docker-deployment.json' },
{ output: '.env.example' },
{ output: 'deploy-docker-auto.ps1' },
{ output: 'build-image-tar.ps1' },
{ output: 'README.DOCKER.md' }
];
// Add nginx.conf for static sites
if (detection.type === 'static-nginx' || detection.type === 'flutter-web') {
files.push({ output: 'nginx.conf' });
}
return files;
}
/**
* Generate all Docker files for the project
*/
async function generateFiles(projectPath, detection, config) {
const context = buildTemplateContext(config, detection);
const generatedFiles = [];
// 1. Generate Dockerfile
try {
const dockerfileTemplate = loadTemplate(`${detection.template}/Dockerfile.template`);
const dockerfile = renderTemplate(dockerfileTemplate, context);
writeFileSync(join(projectPath, 'Dockerfile'), dockerfile);
generatedFiles.push('Dockerfile');
} catch (error) {
console.log(chalk.yellow(`Warning: Could not generate Dockerfile: ${error.message}`));
// Use fallback template
const fallbackDockerfile = generateFallbackDockerfile(detection, context);
writeFileSync(join(projectPath, 'Dockerfile'), fallbackDockerfile);
generatedFiles.push('Dockerfile (fallback)');
}
// 2. Generate docker-compose.yml
try {
const composeTemplate = loadTemplate(`${detection.template}/docker-compose.yml.template`);
const compose = renderTemplate(composeTemplate, context);
writeFileSync(join(projectPath, 'docker-compose.yml'), compose);
generatedFiles.push('docker-compose.yml');
} catch (error) {
const fallbackCompose = generateFallbackCompose(detection, context);
writeFileSync(join(projectPath, 'docker-compose.yml'), fallbackCompose);
generatedFiles.push('docker-compose.yml (fallback)');
}
// 3. Generate .dockerignore
try {
const ignoreTemplate = loadTemplate(`${detection.template}/.dockerignore.template`);
const dockerignore = renderTemplate(ignoreTemplate, context);
writeFileSync(join(projectPath, '.dockerignore'), dockerignore);
generatedFiles.push('.dockerignore');
} catch (error) {
const fallbackIgnore = generateFallbackDockerignore(detection);
writeFileSync(join(projectPath, '.dockerignore'), fallbackIgnore);
generatedFiles.push('.dockerignore (fallback)');
}
// 4. Generate nginx.conf for static sites
if (detection.type === 'static-nginx' || detection.type === 'flutter-web') {
try {
const nginxTemplate = loadTemplate(`${detection.template}/nginx.conf.template`);
const nginx = renderTemplate(nginxTemplate, context);
writeFileSync(join(projectPath, 'nginx.conf'), nginx);
generatedFiles.push('nginx.conf');
} catch (error) {
const fallbackNginx = generateFallbackNginxConf();
writeFileSync(join(projectPath, 'nginx.conf'), fallbackNginx);
generatedFiles.push('nginx.conf (fallback)');
}
}
// 5. Save project config
saveProjectConfig(projectPath, config);
generatedFiles.push('docker-deployment.json');
// 6. Generate .env.example
const envExample = generateEnvExample(detection, context);
writeFileSync(join(projectPath, '.env.example'), envExample);
generatedFiles.push('.env.example');
// 7. Generate PowerShell scripts
const deployScript = generateDeployScript(context);
writeFileSync(join(projectPath, 'deploy-docker-auto.ps1'), deployScript);
generatedFiles.push('deploy-docker-auto.ps1');
const buildScript = generateBuildScript(context);
writeFileSync(join(projectPath, 'build-image-tar.ps1'), buildScript);
generatedFiles.push('build-image-tar.ps1');
// 8. Generate README.DOCKER.md
const readme = generateDockerReadme(detection, context, config);
writeFileSync(join(projectPath, 'README.DOCKER.md'), readme);
generatedFiles.push('README.DOCKER.md');
return generatedFiles;
}
/**
* Generate fallback Dockerfile when template is not found
*/
function generateFallbackDockerfile(detection, context) {
if (detection.type.startsWith('nodejs')) {
return `FROM node:${context.NODE_VERSION}-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
ENV PORT=${context.PORT}
EXPOSE ${context.PORT}
CMD ["node", "${context.ENTRY_POINT}"]
`;
}
if (detection.type.startsWith('python')) {
return `FROM python:${context.PYTHON_VERSION}-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE ${context.PORT}
CMD ["python", "${context.ENTRY_POINT}"]
`;
}
if (detection.type.startsWith('dotnet')) {
return `FROM mcr.microsoft.com/dotnet/sdk:${context.DOTNET_VERSION} AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:${context.DOTNET_VERSION}
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:${context.PORT}
EXPOSE ${context.PORT}
ENTRYPOINT ["dotnet", "${context.ENTRY_POINT}"]
`;
}
// Static site fallback
return `FROM nginx:alpine
COPY . /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`;
}
/**
* Generate fallback docker-compose.yml
*/
function generateFallbackCompose(detection, context) {
let compose = `services:
${context.PROJECT_NAME}:
build: .
container_name: ${context.PROJECT_NAME}
restart: unless-stopped
ports:
- "\${HOST_PORT:-${context.PORT}}:${context.PORT}"
`;
if (context.USE_ENV_FILE) {
compose += ` env_file:
- .env
`;
}
compose += ` environment:
NODE_ENV: production
`;
if (context.HAS_VOLUMES && context.VOLUMES.length > 0) {
compose += ` volumes:\n`;
context.VOLUMES.forEach(vol => {
compose += ` - ${vol}\n`;
});
}
if (context.EXTRA_HOSTS) {
compose += ` extra_hosts:
- "host.docker.internal:host-gateway"
`;
}
return compose;
}
/**
* Generate fallback .dockerignore
*/
function generateFallbackDockerignore(detection) {
return `node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
*.tar
*.tar.gz
Dockerfile
docker-compose.yml
docker-compose.yaml
.dockerignore
README.md
README.DOCKER.md
.vscode
.idea
coverage
.nyc_output
*.log
`;
}
/**
* Generate fallback nginx.conf
*/
function generateFallbackNginxConf() {
return `server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \\.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}
`;
}
/**
* Generate .env.example file
*/
function generateEnvExample(detection, context) {
let env = `# Application Configuration
PORT=${context.PORT}
NODE_ENV=production
# Docker Host Port (change to avoid conflicts)
HOST_PORT=${context.PORT}
`;
if (context.HAS_SSH) {
env += `
# SSH Deployment (used by deploy-docker-auto.ps1)
SSH_HOST=${context.SSH_HOST}
SSH_USER=${context.SSH_USER}
`;
}
return env;
}
/**
* Generate deploy-docker-auto.ps1 (SSH automation)
*/
function generateDeployScript(context) {
return `# Docker Deploy Script (SSH Automation)
# Generated by docker-deployment-manager
param(
[string]$Platform = "linux/amd64",
[string]$ImageTag = "${context.PROJECT_NAME}:latest",
[string]$TarFile = "${context.PROJECT_NAME}.tar",
[switch]$SkipBuild,
[switch]$SkipDeploy
)
$ErrorActionPreference = "Stop"
# Load config if exists
$config = $null
if (Test-Path "docker-deployment.json") {
$config = Get-Content "docker-deployment.json" | ConvertFrom-Json
}
if (-not $SkipBuild) {
Write-Host "Building Docker image..." -ForegroundColor Cyan
docker buildx build --platform $Platform -t $ImageTag --load .
if ($LASTEXITCODE -ne 0) { throw "Docker build failed" }
Write-Host "Saving image to tar..." -ForegroundColor Cyan
docker save -o $TarFile $ImageTag
if ($LASTEXITCODE -ne 0) { throw "Docker save failed" }
Write-Host "Created: $TarFile" -ForegroundColor Green
}
if (-not $SkipDeploy) {
$sshHost = if ($config) { $config.deployment.sshHost } else { "${context.SSH_HOST}" }
$sshUser = if ($config) { $config.deployment.sshUser } else { "${context.SSH_USER}" }
$targetPath = if ($config) { $config.deployment.targetPath } else { "${context.TARGET_PATH}" }
if (-not $sshHost -or -not $sshUser) {
Write-Host "SSH not configured. Use build-image-tar.ps1 for manual deployment." -ForegroundColor Yellow
exit 0
}
$sshTarget = "\${sshUser}@\${sshHost}"
Write-Host "Creating target directory on server..." -ForegroundColor Cyan
ssh $sshTarget "mkdir -p $targetPath"
Write-Host "Copying files to server..." -ForegroundColor Cyan
scp $TarFile "\${sshTarget}:\${targetPath}/"
scp docker-compose.yml "\${sshTarget}:\${targetPath}/"
if (Test-Path ".env") {
scp .env "\${sshTarget}:\${targetPath}/"
}
Write-Host "Loading and starting container on server..." -ForegroundColor Cyan
ssh $sshTarget "cd $targetPath && docker load -i $TarFile && docker compose down 2>/dev/null; docker compose up -d"
Write-Host ""
Write-Host "Deployment complete!" -ForegroundColor Green
Write-Host "View logs: ssh $sshTarget 'cd $targetPath && docker compose logs -f'" -ForegroundColor Gray
}
`;
}
/**
* Generate build-image-tar.ps1 (manual workflow)
*/
function generateBuildScript(context) {
return `# Docker Build Script (Manual Workflow)
# Generated by docker-deployment-manager
param(
[string]$Platform = "linux/amd64",
[string]$ImageTag = "${context.PROJECT_NAME}:latest",
[string]$TarFile = "${context.PROJECT_NAME}.tar"
)
$ErrorActionPreference = "Stop"
Write-Host "Building Docker image..." -ForegroundColor Cyan
docker buildx build --platform $Platform -t $ImageTag --load .
if ($LASTEXITCODE -ne 0) { throw "Docker build failed" }
Write-Host "Saving image to tar..." -ForegroundColor Cyan
docker save -o $TarFile $ImageTag
if ($LASTEXITCODE -ne 0) { throw "Docker save failed" }
Write-Host ""
Write-Host "Build complete!" -ForegroundColor Green
Write-Host "Created: $TarFile" -ForegroundColor Green
Write-Host ""
Write-Host "Manual deployment instructions:" -ForegroundColor Yellow
Write-Host "1. scp $TarFile user@server:~/containers/${context.PROJECT_NAME}/files/" -ForegroundColor Gray
Write-Host "2. scp docker-compose.yml user@server:~/containers/${context.PROJECT_NAME}/files/" -ForegroundColor Gray
Write-Host "3. ssh user@server" -ForegroundColor Gray
Write-Host "4. cd ~/containers/${context.PROJECT_NAME}/files" -ForegroundColor Gray
Write-Host "5. docker load -i $TarFile" -ForegroundColor Gray
Write-Host "6. docker compose up -d" -ForegroundColor Gray
`;
}
/**
* Generate README.DOCKER.md
*/
function generateDockerReadme(detection, context, config) {
return `# Docker Deployment
This project is configured for Docker deployment.
## Project Info
- **Type:** ${detection.description || detection.type}
- **Port:** ${context.PORT}
- **Image:** ${context.PROJECT_NAME}:latest
## Quick Start
### Build and Run Locally
\`\`\`bash
docker compose up --build
\`\`\`
Then visit: http://localhost:${context.PORT}
### Deploy to Server
**Option 1: Automated SSH Deployment**
\`\`\`powershell
.\\deploy-docker-auto.ps1
\`\`\`
**Option 2: Manual Deployment**
\`\`\`powershell
# Build
.\\build-image-tar.ps1
# Copy to server
scp ${context.PROJECT_NAME}.tar user@server:~/containers/${context.PROJECT_NAME}/files/
scp docker-compose.yml user@server:~/containers/${context.PROJECT_NAME}/files/
# On server
ssh user@server
cd ~/containers/${context.PROJECT_NAME}/files
docker load -i ${context.PROJECT_NAME}.tar
docker compose up -d
\`\`\`
## Configuration
### Environment Variables
Copy \`.env.example\` to \`.env\` and configure:
\`\`\`bash
cp .env.example .env
\`\`\`
Key variables:
- \`PORT\` - Application port (default: ${context.PORT})
- \`HOST_PORT\` - Docker host port mapping
### Deployment Settings
Edit \`docker-deployment.json\` to configure:
- SSH host and user
- Target deployment path
- Build settings
## Files
- \`Dockerfile\` - Container build instructions
- \`docker-compose.yml\` - Container runtime configuration
- \`.dockerignore\` - Files excluded from image
- \`docker-deployment.json\` - Deployment configuration
- \`deploy-docker-auto.ps1\` - Automated SSH deployment script
- \`build-image-tar.ps1\` - Manual build script
## Useful Commands
\`\`\`bash
# View logs
docker compose logs -f
# Stop container
docker compose down
# Rebuild and restart
docker compose up --build -d
# Shell into container
docker compose exec ${context.PROJECT_NAME} sh
\`\`\`
---
Generated by docker-deployment-manager
`;
}