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>
332 lines
10 KiB
Bash
Executable File
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
|