From 107f8a26914a4389c85072653397e83cd0437534 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 14 May 2026 00:40:03 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Add=20scaffolding=20for?= =?UTF-8?q?=20Synology=20Docker=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scaffold-synology-docker-deploy.prompt.md | 19 ++ .../scripts/scaffold-synology-deploy.mjs | 320 ++++++++++++++++++ resources/scripts/scaffold-synology-deploy.sh | 16 + .../skills/synology-docker-deploy/SKILL.md | 52 +++ .../references/env-vars.md | 15 + 5 files changed, 422 insertions(+) create mode 100644 resources/prompts/scaffold-synology-docker-deploy.prompt.md create mode 100644 resources/scripts/scaffold-synology-deploy.mjs create mode 100755 resources/scripts/scaffold-synology-deploy.sh create mode 100644 resources/skills/synology-docker-deploy/SKILL.md create mode 100644 resources/skills/synology-docker-deploy/references/env-vars.md diff --git a/resources/prompts/scaffold-synology-docker-deploy.prompt.md b/resources/prompts/scaffold-synology-docker-deploy.prompt.md new file mode 100644 index 0000000..50bc2b4 --- /dev/null +++ b/resources/prompts/scaffold-synology-docker-deploy.prompt.md @@ -0,0 +1,19 @@ +--- +name: "scaffold-synology-docker-deploy" +description: "Scaffold Docker packaging and Synology SSH deployment files into a project with dry-run or apply mode." +agent: "agent" +tools: [read, search, execute, edit] +argument-hint: "project-root= service= stack= mode= app-port= container-port=" +--- + +Scaffold a target project for Synology Docker deployment by running the shared +script. + +Requirements: + +- Use `resources/scripts/scaffold-synology-deploy.sh` instead of manually + writing files. +- Resolve missing required arguments before execution. +- Default to `--mode dry-run` unless the user explicitly asks for apply mode. +- Summarize created or updated files and the next command to run in the target + project. diff --git a/resources/scripts/scaffold-synology-deploy.mjs b/resources/scripts/scaffold-synology-deploy.mjs new file mode 100644 index 0000000..f4dca1f --- /dev/null +++ b/resources/scripts/scaffold-synology-deploy.mjs @@ -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 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" </dev/null 2>&1; then + node_bin="$(command -v node)" +elif command -v nodejs >/dev/null 2>&1; then + node_bin="$(command -v nodejs)" +else + printf 'Node.js is required to scaffold Synology deployment files.\n' >&2 + exit 1 +fi + +exec "$node_bin" "$script_dir/scaffold-synology-deploy.mjs" "$@" diff --git a/resources/skills/synology-docker-deploy/SKILL.md b/resources/skills/synology-docker-deploy/SKILL.md new file mode 100644 index 0000000..21ae15f --- /dev/null +++ b/resources/skills/synology-docker-deploy/SKILL.md @@ -0,0 +1,52 @@ +--- +name: synology-docker-deploy +description: "Use when scaffolding Docker packaging and direct SSH deployment to a Synology host for a project that should be ready to run after setup." +argument-hint: "project-root= service= stack= mode= app-port= container-port=" +--- + +# Synology Docker Deploy + +Use this skill when you want a portable, repeatable setup for Dockerizing a +project and deploying it to Synology over SSH without requiring a container +registry. + +## Procedure + +1. Confirm required inputs: `project-root`, `service`, `stack`, and deploy mode. +2. Run `resources/scripts/scaffold-synology-deploy.sh` to scaffold Docker and + deployment files directly into the target project. +3. Prefer `--mode dry-run` first to review planned changes before writing files. +4. For Node projects, let the scaffold update `package.json` scripts so deploy + commands are available immediately. +5. Copy `deploy/synology/deploy.env.example` to `deploy/synology/deploy.env` + and provide environment values. +6. Run `bash deploy/synology/deploy.sh --dry-run` from the target project to + verify inputs and planned remote actions. +7. Run `bash deploy/synology/deploy.sh` for actual deploy once dry-run output + looks correct. +8. Use `DEPLOY_TARGET=internal` or `DEPLOY_TARGET=external` to switch between + internal and external host routing without changing scripts. + +## Outputs + +- `Dockerfile` (stack-aware default) +- `.dockerignore` +- `deploy/synology/deploy.sh` +- `deploy/synology/deploy.env.example` +- `deploy/synology/compose.yaml` +- `deploy/synology/README.md` +- Optional `package.json` script updates for Node projects + +## Do Not Use + +- Do not use this workflow when the project requires a registry-only release + pipeline. +- Do not use this workflow when Kubernetes manifests are the primary deployment + target. +- Do not store secrets in generated files committed to source control. + +## Notes + +- This workflow is environment-variable-first and keeps secrets out of the repo. +- The generated deploy path uses direct `docker save` + `scp` + remote + `docker load` on Synology. diff --git a/resources/skills/synology-docker-deploy/references/env-vars.md b/resources/skills/synology-docker-deploy/references/env-vars.md new file mode 100644 index 0000000..793551a --- /dev/null +++ b/resources/skills/synology-docker-deploy/references/env-vars.md @@ -0,0 +1,15 @@ +# Required Runtime Variables + +Populate these values in `deploy/synology/deploy.env` in the target project: + +- `SYNOLOGY_USER` +- `SYNOLOGY_SSH_PORT` +- `SYNOLOGY_HOST_INTERNAL` +- `SYNOLOGY_HOST_EXTERNAL` +- `REMOTE_APP_PATH` +- `SERVICE_NAME` +- `IMAGE_NAME` +- `IMAGE_TAG` +- `APP_PORT` +- `CONTAINER_PORT` +- `DEPLOY_TARGET` (`internal` or `external`)