#!/usr/bin/env node // @ts-nocheck import fs from 'node:fs'; import path from 'node:path'; const DEFAULTS = { stack: 'auto', mode: 'dry-run', synologyPort: '22', remotePathBase: '/volume1/docker', }; function usage() { console.error(`Usage: resources/scripts/scaffold-synology-deploy.sh [options] Required: --project-root Target project path. --service Service/app name. Optional: --stack Default: auto --mode Default: dry-run --image-name Default: --image-tag Default: latest --app-port Default: empty (required in deploy.env) --container-port Default: empty (required in deploy.env) --synology-port Default: 22 --remote-path Default: /volume1/docker/ --force Overwrite generated files. --help Show this help text. `); } function parseArgs(argv) { const options = { stack: DEFAULTS.stack, mode: DEFAULTS.mode, force: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--help') { usage(); process.exit(0); } if (arg === '--project-root') { options.projectRoot = argv[index + 1]; index += 1; continue; } if (arg === '--service') { options.service = argv[index + 1]; index += 1; continue; } if (arg === '--stack') { options.stack = argv[index + 1]; index += 1; continue; } if (arg === '--mode') { options.mode = argv[index + 1]; index += 1; continue; } if (arg === '--image-name') { options.imageName = argv[index + 1]; index += 1; continue; } if (arg === '--image-tag') { options.imageTag = argv[index + 1]; index += 1; continue; } if (arg === '--synology-port') { options.synologyPort = argv[index + 1]; index += 1; continue; } if (arg === '--app-port') { options.appPort = argv[index + 1]; index += 1; continue; } if (arg === '--container-port') { options.containerPort = argv[index + 1]; index += 1; continue; } if (arg === '--remote-path') { options.remotePath = argv[index + 1]; index += 1; continue; } if (arg === '--force') { options.force = true; continue; } throw new Error(`Unknown argument: ${arg}`); } if (!options.projectRoot) { throw new Error('--project-root is required.'); } if (!options.service) { throw new Error('--service is required.'); } if (!['auto', 'node', 'python', 'generic'].includes(options.stack)) { throw new Error('--stack must be one of auto|node|python|generic.'); } if (!['dry-run', 'apply'].includes(options.mode)) { throw new Error('--mode must be dry-run or apply.'); } options.projectRoot = path.resolve(options.projectRoot); options.service = slugify(options.service); options.imageName = options.imageName || options.service; options.imageTag = options.imageTag || 'latest'; options.synologyPort = options.synologyPort || DEFAULTS.synologyPort; options.appPort = options.appPort || ''; options.containerPort = options.containerPort || ''; options.remotePath = options.remotePath || `${DEFAULTS.remotePathBase}/${options.service}`; return options; } function slugify(value) { return String(value) .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function detectStack(projectRoot) { if (fs.existsSync(path.join(projectRoot, 'package.json'))) { return 'node'; } if ( fs.existsSync(path.join(projectRoot, 'pyproject.toml')) || fs.existsSync(path.join(projectRoot, 'requirements.txt')) ) { return 'python'; } return 'generic'; } function ensureDir(dirPath, mode) { if (mode === 'dry-run') { return; } fs.mkdirSync(dirPath, { recursive: true }); } function writeFileWithGuard(filePath, content, { mode, force }) { const exists = fs.existsSync(filePath); if (exists && !force) { return { action: 'skipped', filePath }; } const action = exists ? 'updated' : 'created'; if (mode === 'apply') { ensureDir(path.dirname(filePath), mode); fs.writeFileSync(filePath, content, 'utf8'); } return { action, filePath }; } function writeExecutableFile(filePath, content, { mode, force }) { const result = writeFileWithGuard(filePath, content, { mode, force }); if (mode === 'apply' && (result.action === 'created' || result.action === 'updated')) { fs.chmodSync(filePath, 0o755); } return result; } function renderDockerfile(stack) { if (stack === 'node') { return `FROM node:20-alpine\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci --omit=dev\n\nCOPY . .\n\nENV NODE_ENV=production\nEXPOSE 3000\nCMD ["npm", "start"]\n`; } if (stack === 'python') { return `FROM python:3.12-slim\n\nWORKDIR /app\n\nCOPY requirements.txt ./\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY . .\n\nENV PYTHONUNBUFFERED=1\nEXPOSE 8000\nCMD ["python", "-m", "app"]\n`; } return `FROM alpine:3.20\n\nWORKDIR /app\nCOPY . .\n\nEXPOSE 3000\nCMD ["sh", "-c", "echo 'Set your runtime command in Dockerfile' && tail -f /dev/null"]\n`; } function renderDockerIgnore() { return `.git\n.gitignore\nnode_modules\n__pycache__\n*.pyc\n.env\n.env.*\ncoverage\ndist\nbuild\n`; } function renderComposeYaml(service) { return `services:\n app:\n image: \${IMAGE_REF}\n container_name: ${service}\n restart: unless-stopped\n ports:\n - "\${APP_PORT}:\${CONTAINER_PORT}"\n`; } function renderDeployEnvExample(options) { return `# Copy this file to deploy.env and fill values before deploying.\n\nSYNOLOGY_USER=\nSYNOLOGY_SSH_PORT=${options.synologyPort}\n\n# Internal and external host routes.\nSYNOLOGY_HOST_INTERNAL=\nSYNOLOGY_HOST_EXTERNAL=\n\n# internal or external\nDEPLOY_TARGET=internal\n\nSERVICE_NAME=${options.service}\nIMAGE_NAME=${options.imageName}\nIMAGE_TAG=${options.imageTag}\n\n# Required per project\nAPP_PORT=${options.appPort}\nCONTAINER_PORT=${options.containerPort}\n\nREMOTE_APP_PATH=${options.remotePath}\n`; } function renderDeployScript() { return `#!/usr/bin/env bash\n\nset -euo pipefail\n\nscript_dir="$(cd -- "$(dirname -- "\${BASH_SOURCE[0]}")" && pwd -P)"\nproject_root="$(cd -- "\$script_dir/../.." && pwd -P)"\nenv_file="\$script_dir/deploy.env"\ncompose_file="\$script_dir/compose.yaml"\nruntime_env_file="\$script_dir/runtime.env"\n\ndry_run=false\nif [[ "\${1:-}" == "--dry-run" ]]; then\n dry_run=true\nfi\n\nif [[ ! -f "\$env_file" ]]; then\n printf 'Missing %s. Copy deploy.env.example to deploy.env and set values.\\n' "\$env_file" >&2\n exit 1\nfi\n\n# shellcheck source=/dev/null\nsource "\$env_file"\n\nrequired_vars=(SYNOLOGY_USER SYNOLOGY_SSH_PORT SERVICE_NAME IMAGE_NAME IMAGE_TAG APP_PORT CONTAINER_PORT REMOTE_APP_PATH DEPLOY_TARGET)\nfor var_name in "\${required_vars[@]}"; do\n if [[ -z "\${!var_name:-}" ]]; then\n printf 'Missing required variable: %s\\n' "\$var_name" >&2\n exit 1\n fi\ndone\n\ncase "\$DEPLOY_TARGET" in\n internal)\n target_host="\${SYNOLOGY_HOST_INTERNAL:-}"\n ;;\n external)\n target_host="\${SYNOLOGY_HOST_EXTERNAL:-}"\n ;;\n *)\n printf 'DEPLOY_TARGET must be internal or external.\\n' >&2\n exit 1\n ;;\nesac\n\nif [[ -z "\$target_host" ]]; then\n printf 'Selected host for DEPLOY_TARGET=%s is empty.\\n' "\$DEPLOY_TARGET" >&2\n exit 1\nfi\n\nimage_ref="\${IMAGE_NAME}:\${IMAGE_TAG}"\narchive_name="\${SERVICE_NAME}-\${IMAGE_TAG}.tar"\nlocal_archive="\${TMPDIR:-/tmp}/\$archive_name"\nremote_archive="/tmp/\$archive_name"\n\ncat > "\$runtime_env_file" <