🛠️ Add scaffolding for Synology Docker deployment
This commit is contained in:
19
resources/prompts/scaffold-synology-docker-deploy.prompt.md
Normal file
19
resources/prompts/scaffold-synology-docker-deploy.prompt.md
Normal file
@@ -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=<path> service=<name> stack=<auto|node|python|generic> mode=<dry-run|apply> app-port=<port> container-port=<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.
|
||||
320
resources/scripts/scaffold-synology-deploy.mjs
Normal file
320
resources/scripts/scaffold-synology-deploy.mjs
Normal 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();
|
||||
16
resources/scripts/scaffold-synology-deploy.sh
Executable file
16
resources/scripts/scaffold-synology-deploy.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
if command -v node >/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" "$@"
|
||||
52
resources/skills/synology-docker-deploy/SKILL.md
Normal file
52
resources/skills/synology-docker-deploy/SKILL.md
Normal file
@@ -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=<path> service=<name> stack=<auto|node|python|generic> mode=<dry-run|apply> app-port=<port> container-port=<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.
|
||||
@@ -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`)
|
||||
Reference in New Issue
Block a user