🛠️ Add scaffolding for Synology Docker deployment

This commit is contained in:
2026-05-14 00:40:03 +00:00
parent 31975e3088
commit 107f8a2691
5 changed files with 422 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
#!/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 <path> Target project path.
--service <name> Service/app name.
Optional:
--stack <auto|node|python|generic> Default: auto
--mode <dry-run|apply> Default: dry-run
--image-name <name> Default: <service>
--image-tag <tag> Default: latest
--app-port <port> Default: empty (required in deploy.env)
--container-port <port> Default: empty (required in deploy.env)
--synology-port <port> Default: 22
--remote-path <path> Default: /volume1/docker/<service>
--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" <<EOF\nIMAGE_REF=\$image_ref\nAPP_PORT=\$APP_PORT\nCONTAINER_PORT=\$CONTAINER_PORT\nEOF\n\nprintf 'Deploy target: %s (%s)\\n' "\$DEPLOY_TARGET" "\$target_host"\nprintf 'Image: %s\\n' "\$image_ref"\nprintf 'Remote path: %s\\n' "\$REMOTE_APP_PATH"\n\nif [[ "\$dry_run" == true ]]; then\n cat <<EOF\nDry-run commands that would execute:\n docker build -t \$image_ref \"\$project_root\"\n docker save \$image_ref -o \"\$local_archive\"\n scp -P \$SYNOLOGY_SSH_PORT \"\$local_archive\" \"\${SYNOLOGY_USER}@\${target_host}:\$remote_archive\"\n scp -P \$SYNOLOGY_SSH_PORT \"\$compose_file\" \"\$runtime_env_file\" \"\${SYNOLOGY_USER}@\${target_host}:\${REMOTE_APP_PATH}/\"\n ssh -p \$SYNOLOGY_SSH_PORT \"\${SYNOLOGY_USER}@\${target_host}\" \"docker load -i '\$remote_archive' && docker compose --env-file '\${REMOTE_APP_PATH}/runtime.env' -f '\${REMOTE_APP_PATH}/compose.yaml' up -d\"\nEOF\n exit 0\nfi\n\ndocker build -t "\$image_ref" "\$project_root"\ndocker save "\$image_ref" -o "\$local_archive"\n\nssh -p "\$SYNOLOGY_SSH_PORT" "\${SYNOLOGY_USER}@\${target_host}" "mkdir -p '\$REMOTE_APP_PATH'"\nscp -P "\$SYNOLOGY_SSH_PORT" "\$local_archive" "\${SYNOLOGY_USER}@\${target_host}:\$remote_archive"\nscp -P "\$SYNOLOGY_SSH_PORT" "\$compose_file" "\$runtime_env_file" "\${SYNOLOGY_USER}@\${target_host}:\${REMOTE_APP_PATH}/"\n\nssh -p "\$SYNOLOGY_SSH_PORT" "\${SYNOLOGY_USER}@\${target_host}" "set -euo pipefail; docker load -i '\$remote_archive'; cd '\$REMOTE_APP_PATH'; docker compose --env-file runtime.env -f compose.yaml up -d; rm -f '\$remote_archive'"\n\nrm -f "\$local_archive"\nprintf 'Deployment completed.\\n'\n`;
}
function renderDeployReadme(service) {
return `# Synology Deploy\n\nThis directory was generated by the shared Synology deploy scaffold.\n\n## Quickstart\n\n1. Copy \`deploy.env.example\` to \`deploy.env\`.\n2. Set hosts for internal and external access plus SSH credentials.\n3. Run a safe preview:\n \`bash deploy/synology/deploy.sh --dry-run\`\n4. Deploy for real:\n \`bash deploy/synology/deploy.sh\`\n\n## Internal vs External\n\nSet \`DEPLOY_TARGET=internal\` to use \`SYNOLOGY_HOST_INTERNAL\`, or\n\`DEPLOY_TARGET=external\` to use \`SYNOLOGY_HOST_EXTERNAL\`.\n\n## Service\n\nDefault service name for this scaffold: ${service}\n`;
}
function updateNodePackageScripts(projectRoot, mode, options) {
const packageJsonPath = path.join(projectRoot, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return { changed: false, reason: 'package.json not found' };
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const scripts = packageJson.scripts || {};
const desiredScripts = {
'docker:build': `docker build -t ${options.imageName}:${options.imageTag} .`,
'deploy:synology:dry-run': 'bash deploy/synology/deploy.sh --dry-run',
'deploy:synology': 'bash deploy/synology/deploy.sh',
};
let changed = false;
for (const [key, value] of Object.entries(desiredScripts)) {
if (scripts[key] !== value) {
scripts[key] = value;
changed = true;
}
}
if (!changed) {
return { changed: false, reason: 'scripts already present' };
}
if (mode === 'apply') {
packageJson.scripts = scripts;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8');
}
return { changed: true, filePath: packageJsonPath };
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (!fs.existsSync(options.projectRoot) || !fs.statSync(options.projectRoot).isDirectory()) {
throw new Error(`Project root does not exist or is not a directory: ${options.projectRoot}`);
}
const resolvedStack = options.stack === 'auto' ? detectStack(options.projectRoot) : options.stack;
const deployDir = path.join(options.projectRoot, 'deploy', 'synology');
const writes = [];
writes.push(
writeFileWithGuard(path.join(options.projectRoot, 'Dockerfile'), renderDockerfile(resolvedStack), options),
);
writes.push(
writeFileWithGuard(path.join(options.projectRoot, '.dockerignore'), renderDockerIgnore(), options),
);
writes.push(
writeFileWithGuard(path.join(deployDir, 'compose.yaml'), renderComposeYaml(options.service), options),
);
writes.push(
writeFileWithGuard(path.join(deployDir, 'deploy.env.example'), renderDeployEnvExample(options), options),
);
writes.push(
writeExecutableFile(path.join(deployDir, 'deploy.sh'), renderDeployScript(), options),
);
writes.push(
writeFileWithGuard(path.join(deployDir, 'README.md'), renderDeployReadme(options.service), options),
);
const nodeUpdate = resolvedStack === 'node'
? updateNodePackageScripts(options.projectRoot, options.mode, options)
: { changed: false, reason: 'non-node stack' };
console.log(`Mode: ${options.mode}`);
console.log(`Project root: ${options.projectRoot}`);
console.log(`Service: ${options.service}`);
console.log(`Stack: ${resolvedStack}`);
console.log('');
for (const entry of writes) {
console.log(`${entry.action.toUpperCase()}: ${entry.filePath}`);
}
if (nodeUpdate.changed) {
console.log(`UPDATED: ${nodeUpdate.filePath}`);
} else {
console.log(`NODE_SETUP: ${nodeUpdate.reason}`);
}
if (options.mode === 'dry-run') {
console.log('');
console.log('No files were written. Re-run with --mode apply to persist changes.');
}
}
main();