321 lines
13 KiB
JavaScript
321 lines
13 KiB
JavaScript
#!/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();
|