Files
backend/scripts/websites-agent/deploy.sh
T
Alireza HassaniandCursor 4823cf97f6 Require Next.js standalone output in website AI prompt and deploy.
Document mandatory output: "standalone" for shared-VM RAM, and align websites-agent deploy to prefer .next/standalone/server.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:21:44 +03:30

332 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SLUG="${1:-}"
if [[ -z "$SLUG" ]]; then
echo "usage: deploy.sh <slug>" >&2
exit 1
fi
ROOT="/var/www/sites/$SLUG"
LOG="/var/log/websites/deploy-$SLUG.log"
STATUS="/var/log/websites/deploy-$SLUG.status.json"
ECOSYSTEM="/var/www/sites/ecosystem.config.cjs"
exec >>"$LOG" 2>&1
write_status() {
local status="$1"
local detail="${2:-}"
detail="${detail//\\/\\\\}"
detail="${detail//\"/\\\"}"
detail="${detail//$'\n'/ }"
detail="${detail:0:500}"
printf '{"slug":"%s","status":"%s","detail":"%s","at":"%s"}\n' \
"$SLUG" \
"$status" \
"$detail" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
>"$STATUS"
}
fail_snippet() {
if [[ ! -f "$LOG" ]]; then
return 0
fi
local chunk snippet=""
# Only the current deploy (log is appended across runs).
chunk="$(awk 'BEGIN{s=""} /^==== / && /deploy start:/{s=""} {s=s $0 ORS} END{printf "%s", s}' "$LOG")"
snippet="$(
{ printf '%s' "$chunk" | grep -E "Error:|Type error|Module not found|ELIFECYCLE|npm ERR!" || true; } \
| sed 's/\x1b\[[0-9;]*m//g' \
| tail -n 4 \
| tr '\n' ' ' \
| sed 's/[[:space:]]\+/ /g'
)"
if [[ -z "${snippet// }" ]]; then
snippet="$(
printf '%s' "$chunk" \
| tail -n 12 \
| sed 's/\x1b\[[0-9;]*m//g' \
| tr '\n' ' ' \
| sed 's/[[:space:]]\+/ /g'
)"
fi
printf '%s' "$snippet"
}
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy start: $SLUG ===="
write_status "started" "Deploy running"
cleanup_fail() {
local code=$?
trap - ERR
local snippet
snippet="$(fail_snippet)"
if [[ -n "${snippet// }" ]]; then
write_status "failed" "exit $code: $snippet"
else
write_status "failed" "deploy.sh exited with code $code"
fi
exit "$code"
}
trap cleanup_fail ERR
if [[ ! -d "$ROOT/.git" ]]; then
echo "missing site: $ROOT"
write_status "failed" "missing site directory"
exit 1
fi
cd "$ROOT"
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -i /root/.ssh/websites_deploy -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new}"
git fetch origin
# Prefer origin/HEAD, then main, then master (repos may use either).
BRANCH=""
if git rev-parse --verify --quiet origin/HEAD >/dev/null; then
BRANCH="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || true)"
BRANCH="${BRANCH#origin/}"
fi
if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then
if git rev-parse --verify --quiet origin/main >/dev/null; then
BRANCH="main"
elif git rev-parse --verify --quiet origin/master >/dev/null; then
BRANCH="master"
else
echo "could not determine default branch (tried origin/HEAD, main, master)"
write_status "failed" "no origin/main or origin/master"
exit 1
fi
fi
echo "resetting to origin/$BRANCH"
git checkout -B "$BRANCH" "origin/$BRANCH"
git reset --hard "origin/$BRANCH"
if [[ ! -f package.json ]]; then
echo "package.json missing after checkout"
write_status "failed" "package.json missing — empty or wrong branch?"
exit 1
fi
npm ci
npm run build
# Next.js standalone: copy static assets Next does not place in standalone/, then
# point PM2 at server.js and drop the full node_modules tree.
if [[ -f .next/standalone/server.js ]]; then
echo "standalone build detected — preparing runtime bundle"
mkdir -p .next/standalone/.next
rm -rf .next/standalone/.next/static
cp -a .next/static .next/standalone/.next/static
if [[ -d public ]]; then
rm -rf .next/standalone/public
# Follow symlinks (e.g. public/images -> ../src/images) so standalone
# serves real files. cp -a alone would copy a broken relative link.
mkdir -p .next/standalone/public
cp -aL public/. .next/standalone/public/
fi
SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
const fs = require('fs');
const path = process.env.ECOSYSTEM;
const slug = process.env.SLUG;
const root = process.env.ROOT;
let cfg = { apps: [] };
try {
delete require.cache[require.resolve(path)];
cfg = require(path);
if (!Array.isArray(cfg.apps)) cfg.apps = [];
} catch (_) {
cfg = { apps: [] };
}
const app = cfg.apps.find((a) => a.name === slug);
if (!app) {
console.error('ecosystem entry missing for', slug);
process.exit(1);
}
const port =
(app.env && Number(app.env.PORT)) ||
(String(app.args || '').match(/--port\s+(\d+)/) || [])[1] ||
3005;
app.cwd = root + '/.next/standalone';
app.script = 'server.js';
app.args = '';
app.env = {
...(app.env || {}),
NODE_ENV: 'production',
PORT: String(port),
HOSTNAME: '127.0.0.1',
};
const lines = ['module.exports = {', ' apps: ['];
cfg.apps.forEach((entry, idx) => {
lines.push(' {');
lines.push(` name: ${JSON.stringify(entry.name)},`);
lines.push(` cwd: ${JSON.stringify(entry.cwd)},`);
lines.push(` script: ${JSON.stringify(entry.script)},`);
if (entry.args) {
lines.push(` args: ${JSON.stringify(entry.args)},`);
}
lines.push(' env: {');
for (const [key, value] of Object.entries(entry.env || {})) {
lines.push(` ${key}: ${JSON.stringify(String(value))},`);
}
lines.push(' },');
lines.push(` error_file: ${JSON.stringify(entry.error_file)},`);
lines.push(` out_file: ${JSON.stringify(entry.out_file)},`);
lines.push(' time: true,');
if (entry.max_memory_restart) {
lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`);
}
lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },');
});
lines.push(' ],');
lines.push('};');
lines.push('');
fs.writeFileSync(path, lines.join('\n'));
console.log('ecosystem updated for standalone:', slug, 'port', port);
NODE
# Full delete+start so PM2 picks up new cwd/script (restart alone keeps old path).
pm2 delete "$SLUG" || true
pm2 start "$ECOSYSTEM" --only "$SLUG"
pm2 save || true
echo "removing full node_modules (standalone runtime keeps its own minimal copy)"
rm -rf node_modules
# Drop Next build leftovers not needed at runtime (cache + non-standalone trees).
# Keep .next/standalone only.
if [[ -d .next ]]; then
find .next -mindepth 1 -maxdepth 1 ! -name standalone -exec rm -rf {} +
fi
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok (standalone): $SLUG ===="
write_status "success" "Deployed origin/$BRANCH (standalone)"
trap - ERR
exit 0
fi
# Vinext (Vite production server) or legacy `next start`.
# Detect Vinext from package.json / CLI — do not assume every Next-looking
# app has a `.next` production build (vinext writes `dist/` instead).
uses_vinext=false
if node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
const d = Object.assign({}, p.dependencies || {}, p.devDependencies || {});
const s = String((p.scripts || {}).start || "");
const ok = Boolean(d.vinext) || s.includes("vinext") || fs.existsSync("node_modules/vinext/dist/cli.js");
process.exit(ok ? 0 : 1);
'; then
uses_vinext=true
fi
if $uses_vinext; then
if [[ ! -d dist ]]; then
echo "vinext project but dist/ is missing — build did not produce a production bundle"
write_status "failed" "vinext build produced no dist/"
exit 1
fi
echo "no Next standalone output — using vinext start"
elif [[ -f .next/BUILD_ID ]]; then
echo "no standalone output — using next start (legacy)"
else
echo "build produced neither .next/standalone, Vinext dist/, nor .next/BUILD_ID"
write_status "failed" "no production server output (need Next standalone, BUILD_ID, or vinext dist/)"
exit 1
fi
# Reset cwd/script in case a previous deploy used standalone. Do not
# `pm2 restart --update-env`: the deploy agent has PORT=9050 and that
# leaks into the site, so nginx (site port) gets 502.
VINEXT="$uses_vinext" SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
const fs = require('fs');
const path = process.env.ECOSYSTEM;
const slug = process.env.SLUG;
const root = process.env.ROOT;
const usesVinext = process.env.VINEXT === 'true';
let cfg = { apps: [] };
try {
delete require.cache[require.resolve(path)];
cfg = require(path);
if (!Array.isArray(cfg.apps)) cfg.apps = [];
} catch (_) {
cfg = { apps: [] };
}
const app = cfg.apps.find((a) => a.name === slug);
if (!app) {
console.error('ecosystem entry missing for', slug);
process.exit(1);
}
const fromEnv = Number(app.env && app.env.PORT);
const fromArgs = Number((String(app.args || '').match(/--port\s+(\d+)/) || [])[1]);
const port =
[fromEnv, fromArgs].find((p) => Number.isFinite(p) && p > 0 && p !== 9050) || 3005;
app.cwd = root;
app.script = usesVinext
? 'node_modules/vinext/dist/cli.js'
: 'node_modules/next/dist/bin/next';
app.args = 'start --hostname 127.0.0.1 --port ' + port;
app.env = {
NODE_ENV: 'production',
PORT: String(port),
};
const lines = ['module.exports = {', ' apps: ['];
cfg.apps.forEach((entry, idx) => {
lines.push(' {');
lines.push(` name: ${JSON.stringify(entry.name)},`);
lines.push(` cwd: ${JSON.stringify(entry.cwd)},`);
lines.push(` script: ${JSON.stringify(entry.script)},`);
if (entry.args) {
lines.push(` args: ${JSON.stringify(entry.args)},`);
}
lines.push(' env: {');
for (const [key, value] of Object.entries(entry.env || {})) {
lines.push(` ${key}: ${JSON.stringify(String(value))},`);
}
lines.push(' },');
lines.push(` error_file: ${JSON.stringify(entry.error_file)},`);
lines.push(` out_file: ${JSON.stringify(entry.out_file)},`);
lines.push(' time: true,');
if (entry.max_memory_restart) {
lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`);
}
lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },');
});
lines.push(' ],');
lines.push('};');
lines.push('');
fs.writeFileSync(path, lines.join('\n'));
console.log(
'ecosystem updated for',
usesVinext ? 'vinext start' : 'next start',
':',
slug,
'port',
port,
);
NODE
pm2 delete "$SLUG" || true
pm2 start "$ECOSYSTEM" --only "$SLUG"
pm2 save || true
if $uses_vinext; then
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok (vinext): $SLUG ===="
write_status "success" "Deployed origin/$BRANCH (vinext)"
else
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
write_status "success" "Deployed origin/$BRANCH"
fi
trap - ERR